Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions doc/release-notes-25730.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
RPC Wallet
----------

- RPC `listunspent` now has a new argument `include_immature_coinbase`
to include coinbase UTXOs that don't meet the minimum spendability
depth requirement (which before were silently skipped). (#25730)
Comment on lines +1 to +6
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor formatting issues detected by linter.

The release notes content is correct. However, address these markdown issues:

  1. Line 1: Add a top-level heading (e.g., # Release Notes)
  2. Line 6: Add a trailing newline at end of file

Apply this diff:

+# Release Notes
+
 RPC Wallet
 ----------
 
 - RPC `listunspent` now has a new argument `include_immature_coinbase`
   to include coinbase UTXOs that don't meet the minimum spendability
-  depth requirement (which before were silently skipped). (#25730)
+  depth requirement (which before were silently skipped). (#25730)
+
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
RPC Wallet
----------
- RPC `listunspent` now has a new argument `include_immature_coinbase`
to include coinbase UTXOs that don't meet the minimum spendability
depth requirement (which before were silently skipped). (#25730)
# Release Notes
RPC Wallet
----------
- RPC `listunspent` now has a new argument `include_immature_coinbase`
to include coinbase UTXOs that don't meet the minimum spendability
depth requirement (which before were silently skipped). (#25730)
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

1-1: First line in a file should be a top-level heading

(MD041, first-line-heading, first-line-h1)


6-6: Files should end with a single newline character

(MD047, single-trailing-newline)

🤖 Prompt for AI Agents
In doc/release-notes-25730.md around lines 1 to 6, the linter flagged minor
Markdown formatting issues: add a top-level heading at the top of the file (for
example “# Release Notes”) and ensure the file ends with a trailing newline;
update the first line to include the heading and append a final newline
character at EOF so the file passes linting.

24 changes: 14 additions & 10 deletions src/wallet/rpc/coins.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ RPCHelpMan listunspent()
{"coinType", RPCArg::Type::NUM, RPCArg::Default{0}, "Filter coinTypes as follows:\n"
"0=ALL_COINS, 1=ONLY_FULLY_MIXED, 2=ONLY_READY_TO_MIX, 3=ONLY_NONDENOMINATED,\n"
"4=ONLY_MASTERNODE_COLLATERAL, 5=ONLY_COINJOIN_COLLATERAL" },
{"include_immature_coinbase", RPCArg::Type::BOOL, RPCArg::Default{false}, "Include immature coinbase UTXOs"},
},
"query_options"},
},
Expand Down Expand Up @@ -598,10 +599,8 @@ RPCHelpMan listunspent()
include_unsafe = request.params[3].get_bool();
}

CAmount nMinimumAmount = 0;
CAmount nMaximumAmount = MAX_MONEY;
CAmount nMinimumSumAmount = MAX_MONEY;
uint64_t nMaximumCount = 0;
CoinFilterParams filter_coins;
filter_coins.min_amount = 0;
CCoinControl coinControl(CoinType::ALL_COINS);

if (!request.params[4].isNull()) {
Expand All @@ -613,7 +612,8 @@ RPCHelpMan listunspent()
"maximumAmount",
"minimumSumAmount",
"maximumCount",
"coinType"
"coinType",
"include_immature_coinbase"
};

for (const auto& key : options.getKeys()) {
Expand All @@ -623,16 +623,16 @@ RPCHelpMan listunspent()
}

if (options.exists("minimumAmount"))
nMinimumAmount = AmountFromValue(options["minimumAmount"]);
filter_coins.min_amount = AmountFromValue(options["minimumAmount"]);

if (options.exists("maximumAmount"))
nMaximumAmount = AmountFromValue(options["maximumAmount"]);
filter_coins.max_amount = AmountFromValue(options["maximumAmount"]);

if (options.exists("minimumSumAmount"))
nMinimumSumAmount = AmountFromValue(options["minimumSumAmount"]);
filter_coins.min_sum_amount = AmountFromValue(options["minimumSumAmount"]);

if (options.exists("maximumCount"))
nMaximumCount = options["maximumCount"].getInt<int64_t>();
filter_coins.max_count = options["maximumCount"].getInt<int64_t>();

if (options.exists("coinType")) {
int64_t nCoinType = options["coinType"].getInt<int64_t>();
Expand All @@ -643,6 +643,10 @@ RPCHelpMan listunspent()

coinControl.nCoinType = static_cast<CoinType>(nCoinType);
}

if (options.exists("include_immature_coinbase")) {
filter_coins.include_immature_coinbase = options["include_immature_coinbase"].get_bool();
}
}

// Make sure the results are valid at least up to the most recent block
Expand All @@ -658,7 +662,7 @@ RPCHelpMan listunspent()
coinControl.m_include_unsafe_inputs = include_unsafe;

LOCK(pwallet->cs_wallet);
vecOutputs = AvailableCoinsListUnspent(*pwallet, &coinControl, nMinimumAmount, nMaximumAmount, nMinimumSumAmount, nMaximumCount).all();
vecOutputs = AvailableCoinsListUnspent(*pwallet, &coinControl, filter_coins).all();
}

LOCK(pwallet->cs_wallet);
Expand Down
39 changes: 12 additions & 27 deletions src/wallet/spend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,7 @@ void CoinsResult::clear()
CoinsResult AvailableCoins(const CWallet& wallet,
const CCoinControl* coinControl,
std::optional<CFeeRate> feerate,
const CAmount& nMinimumAmount,
const CAmount& nMaximumAmount,
const CAmount& nMinimumSumAmount,
const uint64_t nMaximumCount,
bool only_spendable)
const CoinFilterParams& params)
{
AssertLockHeld(wallet.cs_wallet);

Expand All @@ -123,7 +119,7 @@ CoinsResult AvailableCoins(const CWallet& wallet,
const uint256& wtxid = pwtx->GetHash();
const CWalletTx& wtx = *pwtx;

if (wallet.IsTxImmatureCoinBase(wtx))
if (wallet.IsTxImmatureCoinBase(wtx) && !params.include_immature_coinbase)
continue;

int nDepth = wallet.GetTxDepthInMainChain(wtx);
Expand Down Expand Up @@ -182,7 +178,7 @@ CoinsResult AvailableCoins(const CWallet& wallet,
} // no default case, so the compiler can warn about missing cases
if (!found) continue;

if (output.nValue < nMinimumAmount || output.nValue > nMaximumAmount)
if (output.nValue < params.min_amount || output.nValue > params.max_amount)
continue;

if (coinControl && coinControl->HasSelected() && !coinControl->m_allow_other_inputs && !coinControl->IsSelected(outpoint))
Expand Down Expand Up @@ -213,7 +209,7 @@ CoinsResult AvailableCoins(const CWallet& wallet,
bool spendable = ((mine & ISMINE_SPENDABLE) != ISMINE_NO) || (((mine & ISMINE_WATCH_ONLY) != ISMINE_NO) && (coinControl && coinControl->fAllowWatchOnly && solvable));

// Filter by spendable outputs only
if (!spendable && only_spendable) continue;
if (!spendable && params.only_spendable) continue;

// When parsing a scriptPubKey, Solver returns the parsed pubkeys or hashes (depending on the script)
// We don't need those here, so we are leaving them in return_values_unused
Expand Down Expand Up @@ -250,14 +246,14 @@ CoinsResult AvailableCoins(const CWallet& wallet,
// Cache total amount as we go
result.total_amount += output.nValue;
// Checks the sum amount of all UTXO's.
if (nMinimumSumAmount != MAX_MONEY) {
if (result.total_amount >= nMinimumSumAmount) {
if (params.min_sum_amount != MAX_MONEY) {
if (result.total_amount >= params.min_sum_amount) {
return result;
}
}

// Checks the maximum number of UTXO's.
if (nMaximumCount > 0 && result.size() >= nMaximumCount) {
if (params.max_count > 0 && result.size() >= params.max_count) {
return result;
}
}
Expand All @@ -266,21 +262,16 @@ CoinsResult AvailableCoins(const CWallet& wallet,
return result;
}

CoinsResult AvailableCoinsListUnspent(const CWallet& wallet, const CCoinControl* coinControl, const CAmount& nMinimumAmount, const CAmount& nMaximumAmount, const CAmount& nMinimumSumAmount, const uint64_t nMaximumCount)
CoinsResult AvailableCoinsListUnspent(const CWallet& wallet, const CCoinControl* coinControl, CoinFilterParams params)
{
return AvailableCoins(wallet, coinControl, /*feerate=*/ std::nullopt, nMinimumAmount, nMaximumAmount, nMinimumSumAmount, nMaximumCount, /*only_spendable=*/false);
params.only_spendable = false;
return AvailableCoins(wallet, coinControl, /*feerate=*/ std::nullopt, params);
}

CAmount GetAvailableBalance(const CWallet& wallet, const CCoinControl* coinControl)
{
LOCK(wallet.cs_wallet);
return AvailableCoins(wallet, coinControl,
/*feerate=*/ std::nullopt,
/*nMinimumAmount=*/ 1,
/*nMaximumAmount=*/ MAX_MONEY,
/*nMinimumSumAmount=*/ MAX_MONEY,
/*nMaximumCount=*/ 0
).total_amount;
return AvailableCoins(wallet, coinControl).total_amount;
}

const CTxOut& FindNonChangeParentOutput(const CWallet& wallet, const CTransaction& tx, int output)
Expand Down Expand Up @@ -894,13 +885,7 @@ static util::Result<CreatedTransactionResult> CreateTransactionInternal(
}

// Get available coins
auto available_coins = AvailableCoins(wallet,
&coin_control,
coin_selection_params.m_effective_feerate,
1, /*nMinimumAmount*/
MAX_MONEY, /*nMaximumAmount*/
MAX_MONEY, /*nMinimumSumAmount*/
0); /*nMaximumCount*/
auto available_coins = AvailableCoins(wallet, &coin_control, coin_selection_params.m_effective_feerate);

// Choose coins to use
std::optional<SelectionResult> result = SelectCoins(wallet, available_coins, /*nTargetValue=*/selection_target, coin_control, coin_selection_params);
Expand Down
25 changes: 18 additions & 7 deletions src/wallet/spend.h
Original file line number Diff line number Diff line change
Expand Up @@ -55,23 +55,34 @@ struct CoinsResult {
CAmount total_amount{0};
};

struct CoinFilterParams {
// Outputs below the minimum amount will not get selected
CAmount min_amount{1};
// Outputs above the maximum amount will not get selected
CAmount max_amount{MAX_MONEY};
// Return outputs until the minimum sum amount is covered
CAmount min_sum_amount{MAX_MONEY};
// Maximum number of outputs that can be returned
uint64_t max_count{0};
// By default, return only spendable outputs
bool only_spendable{true};
// By default, do not include immature coinbase outputs
bool include_immature_coinbase{false};
};

/**
* Populate the CoinsResult struct with vectors of available COutputs, organized by OutputType.
*/
CoinsResult AvailableCoins(const CWallet& wallet,
const CCoinControl* coinControl = nullptr,
std::optional<CFeeRate> feerate = std::nullopt,
const CAmount& nMinimumAmount = 1,
const CAmount& nMaximumAmount = MAX_MONEY,
const CAmount& nMinimumSumAmount = MAX_MONEY,
const uint64_t nMaximumCount = 0,
bool only_spendable = true) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet);
const CoinFilterParams& params = {}) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet);

/**
* Wrapper function for AvailableCoins which skips the `feerate` parameter. Use this function
* Wrapper function for AvailableCoins which skips the `feerate` and `CoinFilterParams::only_spendable` parameters. Use this function
* to list all available coins (e.g. listunspent RPC) while not intending to fund a transaction.
*/
CoinsResult AvailableCoinsListUnspent(const CWallet& wallet, const CCoinControl* coinControl = nullptr, const CAmount& nMinimumAmount = 1, const CAmount& nMaximumAmount = MAX_MONEY, const CAmount& nMinimumSumAmount = MAX_MONEY, const uint64_t nMaximumCount = 0) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet);
CoinsResult AvailableCoinsListUnspent(const CWallet& wallet, const CCoinControl* coinControl = nullptr, CoinFilterParams params = {}) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet);

CAmount GetAvailableBalance(const CWallet& wallet, const CCoinControl* coinControl = nullptr);

Expand Down
10 changes: 10 additions & 0 deletions test/functional/wallet_balance.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,18 @@ def run_test(self):
self.log.info("Mining blocks ...")
self.generate(self.nodes[0], 1)
self.generate(self.nodes[1], 1)

# Verify listunspent returns immature coinbase if 'include_immature_coinbase' is set
assert_equal(len(self.nodes[0].listunspent(query_options={'include_immature_coinbase': True})), 1)
assert_equal(len(self.nodes[0].listunspent(query_options={'include_immature_coinbase': False})), 0)

self.generatetoaddress(self.nodes[1], COINBASE_MATURITY + 1, ADDRESS_WATCHONLY)

# Verify listunspent returns all immature coinbases if 'include_immature_coinbase' is set
# For now, only the legacy wallet will see the coinbases going to the imported 'ADDRESS_WATCHONLY'
assert_equal(len(self.nodes[0].listunspent(query_options={'include_immature_coinbase': False})), 1 if self.options.descriptors else 2)
assert_equal(len(self.nodes[0].listunspent(query_options={'include_immature_coinbase': True})), 1 if self.options.descriptors else COINBASE_MATURITY + 2)

if not self.options.descriptors:
# Tests legacy watchonly behavior which is not present (and does not need to be tested) in descriptor wallets
assert_equal(self.nodes[0].getbalances()['mine']['trusted'], 500)
Expand Down
Loading