Merge bitcoin/bitcoin#29278: Wallet: Add `maxfeerate` wallet startup option
What changed, and why it matters
This commit adds a new Bitcoin Core wallet startup option called -maxfeerate. It lets users set a maximum fee rate (fee per unit of transaction size) that the wallet will allow when creating or broadcasting transactions. Previously, the wallet only had -maxtxfee, which limits the total absolute fee. The change also makes the error messages clearer so users can tell whether a transaction was rejected because of the total fee or the fee rate. It is a defensive feature, not a fix for an active vulnerability, and it helps prevent users from accidentally paying extremely high per-byte fees.
No urgent action is required. Operators and developers should review the new -maxfeerate default and consider whether it matches their risk tolerance. Wallet users who previously relied on -maxtxfee alone should understand that transactions can now also be rejected based on fee rate. Review downstream integrations that call BroadcastTransaction or wallet RPCs to ensure they handle the new MAX_FEE_RATE_EXCEEDED error correctly.
Security signals we found
New wallet startup option -maxfeerate to cap transaction fee rate
New transaction error type MAX_FEE_RATE_EXCEEDED
BroadcastTransaction now checks both max absolute fee and max fee rate
Wallet creation, fee bumping, and sendall paths enforce the new limit
Default cap of 10,000 sat/vB prevents accidental overpayment
Functional tests added to verify enforcement and edge cases
Evidence from the diff
The merge commit introduces -maxfeerate as a wallet startup option and enforces it at transaction creation, fee bumping, and broadcast time. A new TransactionError::MAX_FEE_RATE_EXCEEDED is added, and BroadcastTransaction now accepts both a max absolute fee (max_tx_fee) and a max fee rate (max_tx_fee_rate). Wallet code paths in CreateTransactionInternal, feebumper::CheckFeeRate, sendall, and sendtoaddress now reject transactions whose fee rate exceeds the configured limit. The default is 0.10 BTC/kvB (10,000 sat/vB). The change also renames m_default_max_tx_fee to m_max_tx_fee and updates related interfaces, tests, and GUI strings. A warning is added when -maxtxfee conflicts with -minrelaytxfee, and -maxfeerate below -minrelaytxfee is rejected at startup.
Changed components
src/wallet/wallet.cppsrc/wallet/wallet.hsrc/wallet/spend.cppsrc/wallet/feebumper.cppsrc/wallet/rpc/spend.cppsrc/node/transaction.cppsrc/node/transaction.hsrc/node/types.hsrc/interfaces/chain.hsrc/interfaces/node.hsrc/rpc/mempool.cppsrc/qt/walletmodel.cppsrc/qt/sendcoinsdialog.cppsrc/qt/psbtoperationsdialog.cppInspect captured patch +303 / −79
### doc/release-notes-29278.md
@@ -0,0 +1,17 @@
+Wallet
+======
+
+- A new wallet startup option `-maxfeerate` is added.
+- This option sets the upper fee rate limit for wallet transactions.
+- The wallet will not submit or broadcast transactions whose fee rate exceeds `-maxfeerate`.
+- The default is 0.10 BTC/kvB (10,000 sat/vB).
+
+RPC
+===
+
+- Transaction broadcasts now check the specified `maxfeerate` limit and fail
+ if the transaction fee rate exceeds it.
+- Transaction broadcast fee limit error messages are now more specific:
+ `-maxtxfee` errors return "Fee exceeds maximum configured by user
+ (maxtxfee)", while `maxfeerate` errors return a `MAX_FEE_RATE_EXCEEDED`
+ error with "Fee rate exceeds maximum configured by user (maxfeerate)".
### src/common/messages.cpp
@@ -129,7 +129,9 @@ bilingual_str TransactionErrorString(const TransactionError err)
case TransactionError::MEMPOOL_ERROR:
return Untranslated("Mempool internal error");
case TransactionError::MAX_FEE_EXCEEDED:
- return Untranslated("Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)");
+ return Untranslated("Fee exceeds maximum configured by user (maxtxfee)");
+ case TransactionError::MAX_FEE_RATE_EXCEEDED:
+ return Untranslated("Fee rate exceeds maximum configured by user (maxfeerate)");
case TransactionError::MAX_BURN_EXCEEDED:
return Untranslated("Unspendable output exceeds maximum configured by user (maxburnamount)");
case TransactionError::INVALID_PACKAGE:
### src/dummywallet.cpp
@@ -37,6 +37,7 @@ void DummyWalletInit::AddWalletOptions(ArgsManager& argsman) const
"-keypool=<n>",
"-maxapsfee=<n>",
"-maxtxfee=<amt>",
+ "-maxfeerate=<amt>",
"-mintxfee=<amt>",
"-signer=<cmd>",
"-spendzeroconfchange",
### src/interfaces/chain.h
@@ -198,12 +198,14 @@ class Chain
//! @param[in] tx Transaction to process.
//! @param[in] max_tx_fee Don't add the transaction to the mempool or
//! broadcast it if its fee is higher than this.
+ //! @param[in] max_tx_fee_rate reject txs with fee rate higher than this (if CFeeRate(0), the fee rate is not checked)
//! @param[in] broadcast_method Whether to add the transaction to the
//! mempool and how/whether to broadcast it.
//! @param[out] err_string Set if an error occurs.
//! @return False if the transaction could not be added due to the fee or for another reason.
virtual bool broadcastTransaction(const CTransactionRef& tx,
const CAmount& max_tx_fee,
+ const CFeeRate& max_tx_fee_rate,
node::TxBroadcast broadcast_method,
std::string& err_string) = 0;
### src/interfaces/node.h
@@ -204,7 +204,7 @@ class Node
virtual std::optional<Coin> getUnspentOutput(const COutPoint& output) = 0;
//! Broadcast transaction.
- virtual node::TransactionError broadcastTransaction(CTransactionRef tx, CAmount max_tx_fee, std::string& err_string) = 0;
+ virtual node::TransactionError broadcastTransaction(CTransactionRef tx, CAmount max_tx_fee, CFeeRate max_tx_fee_rate, std::string& err_string) = 0;
//! Get wallet loader.
virtual WalletLoader& walletLoader() = 0;
### src/interfaces/wallet.h
@@ -278,7 +278,7 @@ class Wallet
virtual OutputType getDefaultAddressType() = 0;
//! Get max tx fee.
- virtual CAmount getDefaultMaxTxFee() = 0;
+ virtual CAmount getMaxTxFee() = 0;
// Remove wallet.
virtual void remove() = 0;
### src/node/interfaces.cpp
@@ -372,14 +372,9 @@ class NodeImpl : public Node
LOCK(::cs_main);
return chainman().ActiveChainstate().CoinsTip().GetCoin(output);
}
- TransactionError broadcastTransaction(CTransactionRef tx, CAmount max_tx_fee, std::string& err_string) override
+ TransactionError broadcastTransaction(CTransactionRef tx, CAmount max_tx_fee, CFeeRate max_tx_fee_rate, std::string& err_string) override
{
- return BroadcastTransaction(*m_context,
- std::move(tx),
- err_string,
- max_tx_fee,
- TxBroadcast::MEMPOOL_AND_BROADCAST_TO_ALL,
- /*wait_callback=*/false);
+ return BroadcastTransaction(*m_context, std::move(tx), err_string, max_tx_fee, max_tx_fee_rate, /*broadcast_method=*/TxBroadcast::MEMPOOL_AND_BROADCAST_TO_ALL, /*wait_callback=*/false);
}
WalletLoader& walletLoader() override
{
@@ -684,11 +679,12 @@ class ChainImpl : public Chain
return m_node.mempool->HasDescendants(txid);
}
bool broadcastTransaction(const CTransactionRef& tx,
- const CAmount& max_tx_fee,
- TxBroadcast broadcast_method,
- std::string& err_string) override
+ const CAmount& max_tx_fee,
+ const CFeeRate& max_tx_fee_rate,
+ TxBroadcast broadcast_method,
+ std::string& err_string) override
{
- const TransactionError err = BroadcastTransaction(m_node, tx, err_string, max_tx_fee, broadcast_method, /*wait_callback=*/false);
+ const TransactionError err = BroadcastTransaction(m_node, tx, err_string, max_tx_fee, max_tx_fee_rate, broadcast_method, /*wait_callback=*/false);
// Chain clients only care about failures to accept the tx to the mempool. Disregard non-mempool related failures.
// Note: this will need to be updated if BroadcastTransactions() is updated to return other non-mempool failures
// that Chain clients do not need to know about.
### src/node/transaction.cpp
@@ -3,17 +3,19 @@
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+#include <node/transaction.h>
+
#include <consensus/validation.h>
#include <index/txindex.h>
#include <net.h>
#include <net_processing.h>
#include <node/blockstorage.h>
#include <node/context.h>
#include <node/types.h>
+#include <policy/feerate.h>
#include <txmempool.h>
#include <validation.h>
#include <validationinterface.h>
-#include <node/transaction.h>
namespace node {
static TransactionError HandleATMPError(const TxValidationState& state, std::string& err_string_out)
@@ -29,12 +31,7 @@ static TransactionError HandleATMPError(const TxValidationState& state, std::str
}
}
-TransactionError BroadcastTransaction(NodeContext& node,
- const CTransactionRef tx,
- std::string& err_string,
- const CAmount& max_tx_fee,
- TxBroadcast broadcast_method,
- bool wait_callback)
+TransactionError BroadcastTransaction(NodeContext& node, const CTransactionRef tx, std::string& err_string, const CAmount& max_tx_fee, const CFeeRate& max_tx_fee_rate, TxBroadcast broadcast_method, bool wait_callback)
{
// BroadcastTransaction can be called by RPC or by the wallet.
// chainman, mempool and peerman are initialized before the RPC server and wallet are started
@@ -72,14 +69,17 @@ TransactionError BroadcastTransaction(NodeContext& node,
} else {
// Transaction is not already in the mempool.
const bool check_max_fee{max_tx_fee > 0};
- if (check_max_fee || broadcast_method == TxBroadcast::NO_MEMPOOL_PRIVATE_BROADCAST) {
+ const bool check_max_feerate{max_tx_fee_rate > CFeeRate(0)};
+ if (check_max_fee || check_max_feerate || broadcast_method == TxBroadcast::NO_MEMPOOL_PRIVATE_BROADCAST) {
// First, call ATMP with test_accept and check the fee. If ATMP
// fails here, return error immediately.
const MempoolAcceptResult result = node.chainman->ProcessTransaction(tx, /*test_accept=*/ true);
if (result.m_result_type != MempoolAcceptResult::ResultType::VALID) {
return HandleATMPError(result.m_state, err_string);
} else if (check_max_fee && result.m_base_fees.value() > max_tx_fee) {
return TransactionError::MAX_FEE_EXCEEDED;
+ } else if (check_max_feerate && result.m_base_fees.value() > max_tx_fee_rate.GetFee(result.m_vsize.value())) {
+ return TransactionError::MAX_FEE_RATE_EXCEEDED;
}
}
### src/node/transaction.h
@@ -46,6 +46,7 @@ inline constexpr CAmount DEFAULT_MAX_BURN_AMOUNT{0};
* @param[in] tx the transaction to broadcast
* @param[out] err_string reference to std::string to fill with error string if available
* @param[in] max_tx_fee reject txs with fees higher than this (if 0, accept any fee)
+ * @param[in] max_tx_fee_rate reject txs with fee rate higher than this (if CFeeRate(0), the fee rate is not checked)
* @param[in] broadcast_method whether to add the transaction to the mempool and how to broadcast it
* @param[in] wait_callback wait until callbacks have been processed to avoid stale result due to a sequentially RPC.
* return error
@@ -54,6 +55,7 @@ inline constexpr CAmount DEFAULT_MAX_BURN_AMOUNT{0};
CTransactionRef tx,
std::string& err_string,
const CAmount& max_tx_fee,
+ const CFeeRate& max_tx_fee_rate,
TxBroadcast broadcast_method,
bool wait_callback);
### src/node/types.h
@@ -23,6 +23,7 @@ enum class TransactionError {
MEMPOOL_REJECTED,
MEMPOOL_ERROR,
MAX_FEE_EXCEEDED,
+ MAX_FEE_RATE_EXCEEDED,
MAX_BURN_EXCEEDED,
INVALID_PACKAGE,
PRIVATE_BROADCAST_FULL,
### src/qt/psbtoperationsdialog.cpp
@@ -10,6 +10,7 @@
#include <key_io.h>
#include <node/psbt.h>
#include <node/types.h>
+#include <policy/feerate.h>
#include <policy/policy.h>
#include <qt/bitcoinunits.h>
#include <qt/forms/ui_psbtoperationsdialog.h>
@@ -119,8 +120,10 @@ void PSBTOperationsDialog::broadcastTransaction()
CTransactionRef tx = MakeTransactionRef(mtx);
std::string err_string;
+ const auto max_raw_tx_fee_rate{DEFAULT_MAX_RAW_TX_FEE_RATE};
+ // TODO: do not use default values for maxtxfee and maxfeerate.
TransactionError error =
- m_client_model->node().broadcastTransaction(tx, DEFAULT_MAX_RAW_TX_FEE_RATE.GetFeePerK(), err_string);
+ m_client_model->node().broadcastTransaction(tx, DEFAULT_MAX_RAW_TX_FEE_RATE.GetFeePerK(), max_raw_tx_fee_rate, err_string);
if (error == TransactionError::OK) {
showStatus(tr("Transaction broadcast successfully! Transaction ID: %1")
### src/qt/sendcoinsdialog.cpp
@@ -733,7 +733,7 @@ void SendCoinsDialog::processSendCoinsReturn(const WalletModel::SendCoinsReturn
msgParams.second = CClientUIInterface::MSG_ERROR;
break;
case WalletModel::AbsurdFee:
- msgParams.first = tr("A fee higher than %1 is considered an absurdly high fee.").arg(BitcoinUnits::formatWithUnit(model->getOptionsModel()->getDisplayUnit(), model->wallet().getDefaultMaxTxFee()));
+ msgParams.first = tr("A fee higher than %1 is considered an absurdly high fee.").arg(BitcoinUnits::formatWithUnit(model->getOptionsModel()->getDisplayUnit(), model->wallet().getMaxTxFee()));
break;
case WalletModel::OK:
return;
### src/qt/walletmodel.cpp
@@ -219,8 +219,8 @@ WalletModel::SendCoinsReturn WalletModel::prepareTransaction(WalletModelTransact
// Reject absurdly high fee. (This can never happen because the
// wallet never creates transactions with fee greater than
- // m_default_max_tx_fee. This merely a belt-and-suspenders check).
- if (nFeeRequired > m_wallet->getDefaultMaxTxFee()) {
+ // m_max_tx_fee. This is merely a belt-and-suspenders check).
+ if (nFeeRequired > m_wallet->getMaxTxFee()) {
return AbsurdFee;
}
} catch (const std::runtime_error& err) {
### src/rpc/mempool.cpp
@@ -143,8 +143,6 @@ static RPCMethod sendrawtransaction()
const CFeeRate max_raw_tx_fee_rate{ParseFeeRate(self.Arg<UniValue>("maxfeerate"))};
- int64_t virtual_size = GetVirtualTransactionSize(*tx);
- CAmount max_raw_tx_fee = max_raw_tx_fee_rate.GetFee(virtual_size);
std::string err_string;
AssertLockNotHeld(cs_main);
@@ -164,7 +162,8 @@ static RPCMethod sendrawtransaction()
const TransactionError err = BroadcastTransaction(node,
tx,
err_string,
- max_raw_tx_fee,
+ /*max_tx_fee=*/0,
+ max_raw_tx_fee_rate,
method,
/*wait_callback=*/true);
if (TransactionError::OK != err) {
@@ -1518,12 +1517,7 @@ static RPCMethod submitpackage()
// We do not expect an error here; we are only broadcasting things already/still in mempool
std::string err_string;
- const auto err = BroadcastTransaction(node,
- tx,
- err_string,
- /*max_tx_fee=*/0,
- node::TxBroadcast::MEMPOOL_AND_BROADCAST_TO_ALL,
- /*wait_callback=*/true);
+ const auto err = BroadcastTransaction(node, tx, err_string, /*max_tx_fee=*/0, /*max_tx_fee_rate=*/CFeeRate(0), /*broadcast_method=*/node::TxBroadcast::MEMPOOL_AND_BROADCAST_TO_ALL, /*wait_callback=*/true);
if (err != TransactionError::OK) {
throw JSONRPCTransactionError(err,
strprintf("transaction broadcast failed: %s (%d transactions were broadcast successfully)",
### src/test/fuzz/kitchen_sink.cpp
@@ -29,6 +29,7 @@ constexpr TransactionError ALL_TRANSACTION_ERROR[] = {
TransactionError::MEMPOOL_REJECTED,
TransactionError::MEMPOOL_ERROR,
TransactionError::MAX_FEE_EXCEEDED,
+ TransactionError::MAX_FEE_RATE_EXCEEDED,
};
}; // namespace
### src/wallet/feebumper.cpp
@@ -106,8 +106,15 @@ static feebumper::Result CheckFeeRate(const CWallet& wallet, const CMutableTrans
return feebumper::Result::INVALID_PARAMETER;
}
+ const CFeeRate new_feerate{new_total_fee, static_cast<int32_t>(maxTxSize)};
+ if (new_feerate > wallet.m_max_tx_fee_rate) {
+ errors.push_back(Untranslated(strprintf("New fee rate %s %s/kvB is too high (cannot be higher than -maxfeerate %s %s/kvB)",
+ FormatMoney(new_feerate.GetFeePerK()), CURRENCY_UNIT, FormatMoney(wallet.m_max_tx_fee_rate.GetFeePerK()), CURRENCY_UNIT)));
+ return feebumper::Result::WALLET_ERROR;
+ }
+
// Check that in all cases the new fee doesn't violate maxTxFee
- const CAmount max_tx_fee = wallet.m_default_max_tx_fee;
+ const CAmount max_tx_fee = wallet.m_max_tx_fee;
if (new_total_fee > max_tx_fee) {
errors.push_back(Untranslated(strprintf("Specified or calculated fee %s is too high (cannot be higher than -maxtxfee %s)",
FormatMoney(new_total_fee), FormatMoney(max_tx_fee))));
### src/wallet/init.cpp
@@ -61,6 +61,7 @@ void WalletInit::AddWalletOptions(ArgsManager& argsman) const
argsman.AddArg("-maxapsfee=<n>", strprintf("Spend up to this amount in additional (absolute) fees (in %s) if it allows the use of partial spend avoidance (default: %s)", CURRENCY_UNIT, FormatMoney(DEFAULT_MAX_AVOIDPARTIALSPEND_FEE)), ArgsManager::ALLOW_ANY, OptionsCategory::WALLET);
argsman.AddArg("-maxtxfee=<amt>", strprintf("Maximum total fees (in %s) to use in a single wallet transaction; setting this too low may abort large transactions (default: %s)",
CURRENCY_UNIT, FormatMoney(DEFAULT_TRANSACTION_MAXFEE)), ArgsManager::ALLOW_ANY, OptionsCategory::DEBUG_TEST);
+ argsman.AddArg("-maxfeerate=<amt>", strprintf("Maximum fee rate (in %s/kvB) for wallet transactions (default: %s)", CURRENCY_UNIT, FormatMoney(DEFAULT_MAX_TRANSACTION_FEERATE.GetFeePerK())), ArgsManager::ALLOW_ANY, OptionsCategory::WALLET);
argsman.AddArg("-mintxfee=<amt>", strprintf("Fee rates (in %s/kvB) smaller than this are considered zero fee for transaction creation (default: %s)",
CURRENCY_UNIT, FormatMoney(DEFAULT_TRANSACTION_MINFEE)), ArgsManager::ALLOW_ANY, OptionsCategory::WALLET);
#ifdef ENABLE_EXTERNAL_SIGNER
### src/wallet/interfaces.cpp
@@ -500,7 +500,7 @@ class WalletImpl : public Wallet
return spk_man != nullptr;
}
OutputType getDefaultAddressType() override { return m_wallet->m_default_address_type; }
- CAmount getDefaultMaxTxFee() override { return m_wallet->m_default_max_tx_fee; }
+ CAmount getMaxTxFee() override { return m_wallet->m_max_tx_fee; }
void remove() override
{
RemoveWallet(m_context, m_wallet, /*load_on_start=*/false);
### src/wallet/rpc/spend.cpp
@@ -1517,11 +1517,15 @@ RPCMethod sendall()
}
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);
+ const CAmount actual_fee{fee_from_size + total_bump_fees.value_or(0)};
+ CAmount effective_value = total_input_value - actual_fee;
- if (fee_from_size > pwallet->m_default_max_tx_fee) {
+ if (actual_fee > pwallet->m_max_tx_fee) {
throw JSONRPCError(RPC_WALLET_ERROR, TransactionErrorString(TransactionError::MAX_FEE_EXCEEDED).original);
}
+ if (actual_fee > pwallet->m_max_tx_fee_rate.GetFee(tx_size.vsize)) {
+ throw JSONRPCError(RPC_WALLET_ERROR, TransactionErrorString(TransactionError::MAX_FEE_RATE_EXCEEDED).original);
+ }
if (effective_value <= 0) {
if (send_max) {
### src/wallet/spend.cpp
@@ -1418,10 +1418,15 @@ static util::Result<CreatedTransactionResult> CreateTransactionInternal(
return util::Error{_("Transaction too large")};
}
- if (current_fee > wallet.m_default_max_tx_fee) {
+ if (current_fee > wallet.m_max_tx_fee) {
return util::Error{TransactionErrorString(TransactionError::MAX_FEE_EXCEEDED)};
}
+ const int64_t tx_vsize{GetVirtualTransactionSize(*tx)};
+ if (current_fee > wallet.m_max_tx_fee_rate.GetFee(tx_vsize)) {
+ return util::Error{TransactionErrorString(TransactionError::MAX_FEE_RATE_EXCEEDED)};
+ }
+
if (gArgs.GetBoolArg("-walletrejectlongchains", DEFAULT_WALLET_REJECT_LONG_CHAINS)) {
// Lastly, ensure this tx will pass the mempool's chain limits
auto result = wallet.chain().checkChainLimits(tx);
### src/wallet/test/wallet_tests.cpp
@@ -20,13 +20,14 @@
#include <addresstype.h>
#include <blockfilter.h>
#include <chain.h>
+#include <consensus/tx_verify.h>
#include <consensus/validation.h>
#include <index/blockfilterindex.h>
#include <interfaces/chain.h>
#include <key_io.h>
-#include <logging.h>
#include <node/blockstorage.h>
#include <node/types.h>
+#include <policy/feerate.h>
#include <policy/policy.h>
#include <rpc/server.h>
#include <script/descriptor.h>
@@ -51,7 +52,6 @@
#include <wallet/test/wallet_test_fixture.h>
#include <boost/test/unit_test.hpp>
-#include <univalue.h>
using node::MAX_BLOCKFILE_SIZE;
@@ -78,6 +78,18 @@ static CMutableTransaction TestSimpleSpend(const CTransaction& from, uint32_t in
return mtx;
}
+static bool BroadcastTestSimpleSpend(interfaces::Chain& chain, ChainstateManager& chainman, const CMutableTransaction& tx, std::string& error)
+{
+ const auto tx_ref{MakeTransactionRef(tx)};
+ const auto tx_sigops = WITH_LOCK(::cs_main, return GetTransactionSigOpCost(
+ *tx_ref, chainman.ActiveChainstate().CoinsTip(), STANDARD_SCRIPT_VERIFY_FLAGS));
+ const auto tx_vsize{GetVirtualTransactionSize(*tx_ref, tx_sigops, nBytesPerSigOp)};
+ const auto tx_feerate{CFeeRate{DEFAULT_TRANSACTION_MAXFEE, static_cast<int32_t>(tx_vsize)}};
+ // TestSimpleSpend pays a high fee; use a limit just above its feerate.
+ const auto tx_feerate_limit{CFeeRate{tx_feerate.GetFeePerK() + 1}};
+ return chain.broadcastTransaction(tx_ref, DEFAULT_TRANSACTION_MAXFEE, tx_feerate_limit, node::TxBroadcast::MEMPOOL_NO_BROADCAST, error);
+}
+
static void AddKey(CWallet& wallet, const CKey& key)
{
LOCK(wallet.cs_wallet);
@@ -1289,8 +1301,7 @@ BOOST_FIXTURE_TEST_CASE(CreateWallet, TestChain100Setup)
auto block_tx = TestSimpleSpend(*m_coinbase_txns[0], 0, coinbaseKey, GetScriptForRawPubKey(key.GetPubKey()));
m_coinbase_txns.push_back(CreateAndProcessBlock({block_tx}, GetScriptForRawPubKey(coinbaseKey.GetPubKey())).vtx[0]);
auto mempool_tx = TestSimpleSpend(*m_coinbase_txns[1], 0, coinbaseKey, GetScriptForRawPubKey(key.GetPubKey()));
- BOOST_CHECK(m_node.chain->broadcastTransaction(MakeTransactionRef(mempool_tx), DEFAULT_TRANSACTION_MAXFEE, node::TxBroadcast::MEMPOOL_NO_BROADCAST, error));
-
+ BOOST_CHECK(BroadcastTestSimpleSpend(*m_node.chain, *Assert(m_node.chainman), mempool_tx, error));
// Reload wallet and make sure new transactions are detected despite events
// being blocked
@@ -1331,7 +1342,7 @@ BOOST_FIXTURE_TEST_CASE(CreateWallet, TestChain100Setup)
block_tx = TestSimpleSpend(*m_coinbase_txns[2], 0, coinbaseKey, GetScriptForRawPubKey(key.GetPubKey()));
m_coinbase_txns.push_back(CreateAndProcessBlock({block_tx}, GetScriptForRawPubKey(coinbaseKey.GetPubKey())).vtx[0]);
mempool_tx = TestSimpleSpend(*m_coinbase_txns[3], 0, coinbaseKey, GetScriptForRawPubKey(key.GetPubKey()));
- BOOST_CHECK(m_node.chain->broadcastTransaction(MakeTransactionRef(mempool_tx), DEFAULT_TRANSACTION_MAXFEE, node::TxBroadcast::MEMPOOL_NO_BROADCAST, error));
+ BOOST_CHECK(BroadcastTestSimpleSpend(*m_node.chain, *Assert(m_node.chainman), mempool_tx, error));
m_node.validation_signals->SyncWithValidationInterfaceQueue();
});
wallet = TestLoadWallet(context);
### src/wallet/wallet.cpp
@@ -1855,7 +1855,7 @@ bool CWallet::SubmitTxMemoryPoolAndRelay(CWalletTx& wtx,
// If broadcast fails for any reason, trying to set wtx.m_state here would be incorrect.
// If transaction was previously in the mempool, it should be updated when
// TransactionRemovedFromMempool fires.
- bool ret = chain().broadcastTransaction(wtx.GetTx(), m_default_max_tx_fee, broadcast_method, err_string);
+ bool ret = chain().broadcastTransaction(wtx.GetTx(), m_max_tx_fee, m_max_tx_fee_rate, broadcast_method, err_string);
if (ret) wtx.m_state = TxStateInMempool{};
return ret;
}
@@ -2811,17 +2811,17 @@ bool CWallet::LoadWalletArgs(std::shared_ptr<CWallet> wallet, const WalletContex
}
if (const auto arg{args.GetArg("-maxapsfee")}) {
- const std::string& max_aps_fee{*arg};
- if (max_aps_fee == "-1") {
+ const std::string& max_aps_fee_str{*arg};
+ if (max_aps_fee_str == "-1") {
wallet->m_max_aps_fee = -1;
- } else if (std::optional<CAmount> max_fee = ParseMoney(max_aps_fee)) {
- if (max_fee.value() > HIGH_APS_FEE) {
+ } else if (std::optional<CAmount> max_aps_fee = ParseMoney(max_aps_fee_str)) {
+ if (max_aps_fee.value() > HIGH_APS_FEE) {
warnings.push_back(AmountHighWarn("-maxapsfee") + Untranslated(" ") +
_("This is the maximum transaction fee you pay (in addition to the normal fee) to prioritize partial spend avoidance over regular coin selection."));
}
- wallet->m_max_aps_fee = max_fee.value();
+ wallet->m_max_aps_fee = max_aps_fee.value();
} else {
- error = AmountErrMsg("maxapsfee", max_aps_fee);
+ error = AmountErrMsg("maxapsfee", max_aps_fee_str);
return false;
}
}
@@ -2854,21 +2854,43 @@ bool CWallet::LoadWalletArgs(std::shared_ptr<CWallet> wallet, const WalletContex
}
if (const auto arg{args.GetArg("-maxtxfee")}) {
- std::optional<CAmount> max_fee = ParseMoney(*arg);
- if (!max_fee) {
+ std::optional<CAmount> max_tx_fee = ParseMoney(*arg);
+ if (!max_tx_fee) {
error = AmountErrMsg("maxtxfee", *arg);
return false;
- } else if (max_fee.value() > HIGH_MAX_TX_FEE) {
+ } else if (max_tx_fee.value() > HIGH_MAX_TX_FEE) {
warnings.push_back(strprintf(_("%s is set very high! Fees this large could be paid on a single transaction."), "-maxtxfee"));
}
+ const CFeeRate max_txfee{max_tx_fee.value(), 1000};
+ if (chain && max_txfee < chain->relayMinFee()) {
+ // Wallet prevents creating transactions with fee rates lower than minrelaytxfee.
+ // Also the wallet prevents creating transactions with base fee above maxtxfee.
+ // Warn when a 1kvb transaction, with a base fee set to maxtxfee, has a fee rate less than minrelaytxfee.
+ // It is likely that some transactions with fee rates greater than or equal to the minrelaytxfee will exceed maxtxfee.
+ // In such cases, the wallet won't be able to create transactions. Therefore, warn the user.
+ warnings.push_back(strprintf(_("Invalid amount for %s=<amount>: '%s' conflicts with the minimum relay transaction feerate %s. Please set a higher %s or lower %s"),
+ "-maxtxfee", max_txfee.ToString(), chain->relayMinFee().ToString(), "-maxtxfee", "-minrelaytxfee"));
+ }
+
+ wallet->m_max_tx_fee = max_tx_fee.value();
+ }
- if (chain && CFeeRate{max_fee.value(), 1000} < chain->relayMinFee()) {
+ if (const auto arg{args.GetArg("-maxfeerate")}) {
+ std::optional<CAmount> max_tx_fee_rate = ParseMoney(*arg);
+ if (!max_tx_fee_rate) {
+ error = AmountErrMsg("maxfeerate", *arg);
+ return false;
+ }
+ if (chain && CFeeRate(*max_tx_fee_rate) < chain->relayMinFee()) {
error = strprintf(_("Invalid amount for %s=<amount>: '%s' (must be at least the minrelay fee of %s to prevent stuck transactions)"),
- "-maxtxfee", *arg, chain->relayMinFee().ToString());
+ "-maxfeerate", *arg, chain->relayMinFee().ToString());
return false;
}
+ if (CFeeRate(*max_tx_fee_rate) > HIGH_MAX_TX_FEERATE) {
+ warnings.push_back(strprintf(_("%s is set very high! Fee rate this large could be paid on a single transaction."), "-maxfeerate"));
+ }
- wallet->m_default_max_tx_fee = max_fee.value();
+ wallet->m_max_tx_fee_rate = CFeeRate(*max_tx_fee_rate);
}
if (const auto arg{args.GetArg("-consolidatefeerate")}) {
### src/wallet/wallet.h
@@ -138,10 +138,15 @@ inline constexpr bool DEFAULT_DISABLE_WALLET = false;
inline constexpr bool DEFAULT_WALLETCROSSCHAIN = false;
//! -maxtxfee default
inline constexpr CAmount DEFAULT_TRANSACTION_MAXFEE{COIN / 10};
+//! -maxfeerate default
+inline constexpr CFeeRate DEFAULT_MAX_TRANSACTION_FEERATE{COIN / 10};
//! Discourage users to set fees higher than this amount (in satoshis) per kB
inline constexpr CAmount HIGH_TX_FEE_PER_KB{COIN / 100};
//! -maxtxfee will warn if called with a higher fee than this amount (in satoshis)
inline constexpr CAmount HIGH_MAX_TX_FEE{100 * HIGH_TX_FEE_PER_KB};
+//! A warning will be emitted if -maxfeerate is set higher than this fee rate (in satoshis per kB).
+inline constexpr CFeeRate HIGH_MAX_TX_FEERATE{100 * HIGH_TX_FEE_PER_KB};
+
//! Pre-calculated constants for input size estimation in *virtual size*
inline constexpr size_t DUMMY_NESTED_P2WPKH_INPUT_SIZE = 91;
@@ -715,9 +720,11 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
* CWallet::TransactionChangeType for details).
*/
std::optional<OutputType> m_default_change_type{};
- /** Absolute maximum transaction fee (in satoshis) used by default for the wallet */
- CAmount m_default_max_tx_fee{DEFAULT_TRANSACTION_MAXFEE};
+ /** Absolute maximum transaction fee (in satoshis) used by the wallet */
+ CAmount m_max_tx_fee{DEFAULT_TRANSACTION_MAXFEE};
+ /** Maximum transaction fee rate used for the wallet */
+ CFeeRate m_max_tx_fee_rate{DEFAULT_MAX_TRANSACTION_FEERATE};
/** Number of pre-generated keys/scripts by each spkm (part of the look-ahead process, used to detect payments) */
int64_t m_keypool_size{DEFAULT_KEYPOOL_SIZE};
### test/functional/rpc_psbt.py
@@ -78,10 +78,12 @@
class PSBTTest(BitcoinTestFramework):
def set_test_params(self):
self.num_nodes = 3
+ # Set a high -maxfeerate value for nodes' wallets; some tests require
+ # creating and broadcasting transactions with a high fee rate that exceeds the default.
self.extra_args = [
[],
- ["-changetype=legacy"],
- []
+ ["-changetype=legacy", "-maxfeerate=1"],
+ ["-maxfeerate=1"]
]
# whitelist peers to speed up tx relay / mempool sync
for args in self.extra_args:
@@ -1064,7 +1066,7 @@ def run_test(self):
self.log.info("Test invalid fee rate settings")
for param, value in {("fee_rate", 100000), ("feeRate", 1)}:
- assert_raises_rpc_error(-4, "Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)",
+ assert_raises_rpc_error(-4, "Fee exceeds maximum configured by user (maxtxfee)",
self.nodes[1].walletcreatefundedpsbt, inputs, outputs, 0, {param: value, "add_inputs": True})
assert_raises_rpc_error(-3, "Amount out of range",
self.nodes[1].walletcreatefundedpsbt, inputs, outputs, 0, {param: -1, "add_inputs": True})
@@ -1118,7 +1120,7 @@ def run_test(self):
self.log.info("Test walletcreatefundedpsbt with too-high fee rate produces total fee well above -maxtxfee and raises RPC error")
# previously this was silently capped at -maxtxfee
for bool_add, outputs_array in {True: outputs, False: [{self.nodes[1].getnewaddress(): 1}]}.items():
- msg = "Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)"
+ msg = "Fee exceeds maximum configured by user (maxtxfee)"
assert_raises_rpc_error(-4, msg, self.nodes[1].walletcreatefundedpsbt, inputs, outputs_array, 0, {"fee_rate": 1000000, "add_inputs": bool_add})
assert_raises_rpc_error(-4, msg, self.nodes[1].walletcreatefundedpsbt, inputs, outputs_array, 0, {"feeRate": 1, "add_inputs": bool_add})
### test/functional/rpc_rawtransaction.py
@@ -413,7 +413,7 @@ def sendrawtransaction_tests(self):
def sendrawtransaction_testmempoolaccept_tests(self):
self.log.info("Test sendrawtransaction/testmempoolaccept with maxfeerate")
- fee_exceeds_max = "Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)"
+ fee_rate_exceeds_max = "Fee rate exceeds maximum configured by user (maxfeerate)"
# Test a transaction with a small fee.
# Fee rate is 0.00100000 BTC/kvB
@@ -423,7 +423,7 @@ def sendrawtransaction_testmempoolaccept_tests(self):
assert_equal(testres['allowed'], False)
assert_equal(testres['reject-reason'], 'max-fee-exceeded')
# and sendrawtransaction should throw
- assert_raises_rpc_error(-25, fee_exceeds_max, self.nodes[2].sendrawtransaction, tx['hex'], 0.00001000)
+ assert_raises_rpc_error(-25, fee_rate_exceeds_max, self.nodes[2].sendrawtransaction, tx['hex'], 0.00001000)
# and the following calls should both succeed
testres = self.nodes[2].testmempoolaccept(rawtxs=[tx['hex']])[0]
assert_equal(testres['allowed'], True)
@@ -437,12 +437,18 @@ def sendrawtransaction_testmempoolaccept_tests(self):
assert_equal(testres['allowed'], False)
assert_equal(testres['reject-reason'], 'max-fee-exceeded')
# and sendrawtransaction should throw
- assert_raises_rpc_error(-25, fee_exceeds_max, self.nodes[2].sendrawtransaction, tx['hex'])
+ assert_raises_rpc_error(-25, fee_rate_exceeds_max, self.nodes[2].sendrawtransaction, tx['hex'])
# and the following calls should both succeed
testres = self.nodes[2].testmempoolaccept(rawtxs=[tx['hex']], maxfeerate='0.20000000')[0]
assert_equal(testres['allowed'], True)
self.nodes[2].sendrawtransaction(hexstring=tx['hex'], maxfeerate='0.20000000')
+ self.log.info("Test sendrawtransaction/testmempoolaccept maxfeerate rounding")
+ tx = self.wallet.create_self_transfer(fee=Decimal("0.00000105"))
+ testres = self.nodes[2].testmempoolaccept(rawtxs=[tx['hex']], maxfeerate=Decimal("0.00001009"))[0]
+ assert_equal(testres['allowed'], True)
+ self.nodes[2].sendrawtransaction(hexstring=tx['hex'], maxfeerate=Decimal("0.00001009"))
+
self.log.info("Test sendrawtransaction/testmempoolaccept with tx outputs already in the utxo set")
self.generate(self.nodes[2], 1)
for node in self.nodes:
### test/functional/wallet_bumpfee.py
@@ -14,6 +14,7 @@
make assumptions about execution order.
"""
from decimal import Decimal
+import re
from test_framework.blocktools import (
COINBASE_MATURITY,
@@ -111,6 +112,7 @@ def run_test(self):
test_small_output_with_feerate_succeeds(self, rbf_node, dest_address)
test_no_more_inputs_fails(self, rbf_node, dest_address)
self.test_bump_back_to_yourself()
+ self.test_bumpfee_maxfeerate_includes_bump_fee()
self.test_provided_change_pos(rbf_node)
self.test_single_output()
@@ -132,8 +134,11 @@ def test_invalid_parameters(self, rbf_node, peer_node, dest_address):
assert_raises_rpc_error(-8, "Insufficient total fee 0.00000141", rbf_node.bumpfee, rbfid, fee_rate=INSUFFICIENT)
self.log.info("Test invalid fee rate settings")
- assert_raises_rpc_error(-4, "Specified or calculated fee 0.141 is too high (cannot be higher than -maxtxfee 0.10",
+
+ # Bumping to a very high fee rate above the default -maxfeerate should fail
+ assert_raises_rpc_error(-4, "New fee rate 1.00 BTC/kvB is too high (cannot be higher than -maxfeerate 0.10 BTC/kvB)",
rbf_node.bumpfee, rbfid, fee_rate=TOO_HIGH)
+
# Test fee_rate with zero values.
msg = "Insufficient total fee 0.00"
for zero_value in [0, 0.000, 0.00000000, "0", "0.000", "0.00000000"]:
@@ -240,6 +245,29 @@ def test_bump_back_to_yourself(self):
node.unloadwallet("back_to_yourself")
+ def test_bumpfee_maxfeerate_includes_bump_fee(self):
+ self.log.info("Test that bumpfee enforces -maxfeerate against the total fee including ancestor bump fees")
+ node = self.nodes[1]
+ node.createwallet("bumpfee_maxfeerate")
+ wallet = node.get_wallet_rpc("bumpfee_maxfeerate")
+
+ # One confirmed UTXO to fund from.
+ self.nodes[0].sendtoaddress(wallet.getnewaddress(), 5)
+ self.generate(self.nodes[0], 1)
+
+ # Low-feerate unconfirmed parent, so spending its outputs requires a positive bump fee.
+ wallet.sendtoaddress(wallet.getnewaddress(), 4, fee_rate=2)
+ # Spend the unconfirmed parent output in an RBF tx.
+ child = wallet.send(outputs={self.nodes[0].getnewaddress(): 3}, fee_rate=5)["txid"]
+ assert_equal(wallet.gettransaction(child)["confirmations"], 0)
+
+ # fee_rate equals the default -maxfeerate (10000 sat/vB); the ancestor bump fee pushes the
+ # bumped tx's actual fee rate above the limit, so bumpfee must fail instead of silently
+ # creating a tx that the broadcast-time -maxfeerate check rejects.
+ assert_raises_rpc_error(-4, "is too high (cannot be higher than -maxfeerate", wallet.bumpfee, child, fee_rate=10000)
+
+ node.unloadwallet("bumpfee_maxfeerate")
+
def test_provided_change_pos(self, rbf_node):
self.log.info("Test the original_change_index option")
@@ -542,7 +570,37 @@ def test_maxtxfee_fails(self, rbf_node, dest_address):
self.restart_node(1, ['-maxtxfee=0.000025'] + self.extra_args[1])
rbf_node.walletpassphrase(WALLET_PASSPHRASE, WALLET_PASSPHRASE_TIMEOUT)
rbfid = spend_one_input(rbf_node, dest_address)
- assert_raises_rpc_error(-4, "Unable to create transaction. Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)", rbf_node.bumpfee, rbfid)
+ # When user passed fee rate causes base fee to be above maxtxfee we fail early
+ assert_raises_rpc_error(-4, "Specified or calculated fee 0.0000282 is too high (cannot be higher than -maxtxfee 0.000025)", rbf_node.bumpfee, rbfid, fee_rate=20)
+ self.log.info("Test that a low -maxtxfee, which may prevent tx fee rate from reaching -minrelaytxfee triggers a warning.")
+ low_max_tx_fee = '0.00000100'
+ high_max_tx_fee = '0.001'
+ high_min_relay_fee = '0.00020000'
+ msg = f"Invalid amount for -maxtxfee=<amount>: '{low_max_tx_fee} BTC/kvB' conflicts with the minimum relay transaction feerate {high_min_relay_fee} BTC/kvB. Please set a higher -maxtxfee or lower -minrelaytxfee"
+ self.restart_node(1, extra_args=[f'-minrelaytxfee={high_min_relay_fee}', f'-maxtxfee={low_max_tx_fee}'])
+ warnings = self.nodes[1].createwallet("test-wallet")["warnings"]
+ assert msg in warnings
+
+ self.log.info("Test that a very high -maxtxfee warning does not suppress a -minrelaytxfee conflict warning.")
+ low_conflict_stderr = "Warning: " + msg
+ self.stop_node(1, expected_stderr=low_conflict_stderr)
+ very_high_max_tx_fee = '2.00000000'
+ very_high_min_relay_fee = '3.00000000'
+ high_fee_msg = "-maxtxfee is set very high! Fees this large could be paid on a single transaction."
+ conflict_msg = f"Invalid amount for -maxtxfee=<amount>: '{very_high_max_tx_fee} BTC/kvB' conflicts with the minimum relay transaction feerate {very_high_min_relay_fee} BTC/kvB. Please set a higher -maxtxfee or lower -minrelaytxfee"
+ self.start_node(1, extra_args=[f'-minrelaytxfee={very_high_min_relay_fee}', f'-maxtxfee={very_high_max_tx_fee}'])
+ warnings = self.nodes[1].createwallet("test-wallet-very-high")["warnings"]
+ assert high_fee_msg in warnings
+ assert conflict_msg in warnings
+
+ self.log.info("Test that a -maxtxfee high enough to allow tx fee rate to meet or exceed -minrelaytxfee should start normally.")
+ high_minrelay_msg = "-minrelaytxfee is set very high! The wallet will avoid paying less than the minimum relay fee."
+ self.stop_node(1, expected_stderr=re.compile(
+ f"^Warning: {re.escape(high_fee_msg)}\r?\n"
+ f"{re.escape(conflict_msg)}\r?\n"
+ f"{re.escape(high_minrelay_msg)}$"
+ ))
+ self.start_node(1, extra_args=[f'-minrelaytxfee={high_min_relay_fee}', f'-maxtxfee={high_max_tx_fee}'])
self.restart_node(1, self.extra_args[1])
rbf_node.walletpassphrase(WALLET_PASSPHRASE, WALLET_PASSPHRASE_TIMEOUT)
self.connect_nodes(1, 0)
### test/functional/wallet_create_tx.py
@@ -61,12 +61,12 @@ def test_tx_size_too_large(self):
self.restart_node(0, extra_args=[fee_setting])
assert_raises_rpc_error(
-6,
- "Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)",
+ "Fee exceeds maximum configured by user (maxtxfee)",
lambda: self.nodes[0].sendmany(dummy="", amounts=outputs),
)
assert_raises_rpc_error(
-4,
- "Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)",
+ "Fee exceeds maximum configured by user (maxtxfee)",
lambda: self.nodes[0].fundrawtransaction(hexstring=raw_tx),
)
@@ -77,12 +77,12 @@ def test_tx_size_too_large(self):
assert_raises_rpc_error(
-6,
- "Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)",
+ "Fee exceeds maximum configured by user (maxtxfee)",
lambda: self.nodes[0].sendmany(dummy="", amounts=outputs, fee_rate=fee_rate_sats_per_vb),
)
assert_raises_rpc_error(
-4,
- "Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)",
+ "Fee exceeds maximum configured by user (maxtxfee)",
lambda: self.nodes[0].fundrawtransaction(hexstring=raw_tx, options={'fee_rate': fee_rate_sats_per_vb}),
)
### test/functional/wallet_fundrawtransaction.py
@@ -95,6 +95,11 @@ def unlock_utxos(self, wallet):
wallet.lockunspent(True)
wallet.lockunspent(False, to_keep)
+ def reconnect_nodes(self):
+ self.connect_nodes(0, 1)
+ self.connect_nodes(0, 2)
+ self.connect_nodes(0, 3)
+
def run_test(self):
self.watchonly_utxo = None
self.log.info("Connect nodes, set fees, generate blocks, and sync")
@@ -830,6 +835,8 @@ def test_option_feerate(self):
# With no arguments passed, expect fee of 141 satoshis.
assert_approx(node.fundrawtransaction(rawtx, fee_rate=self.fee_rate_sats_per_vb)["fee"], vexp=0.00000141, vspan=0.00000001)
# Expect fee to be 10,000x higher when an explicit fee rate 10,000x greater is specified.
+ # Restart node with a high -maxfeerate to allow the wallet to create high fee rate transactions
+ self.restart_node(3, extra_args=["-maxfeerate=1"])
result = node.fundrawtransaction(rawtx, fee_rate=10000)
assert_approx(result["fee"], vexp=0.0141, vspan=0.0001)
@@ -853,7 +860,7 @@ def test_option_feerate(self):
self.log.info("Test invalid fee rate settings")
for param, value in {("fee_rate", 100000), ("feeRate", 1.000)}:
- assert_raises_rpc_error(-4, "Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)",
+ assert_raises_rpc_error(-4, "Fee exceeds maximum configured by user (maxtxfee)",
node.fundrawtransaction, rawtx, add_inputs=True, **{param: value})
assert_raises_rpc_error(-3, "Amount out of range",
node.fundrawtransaction, rawtx, add_inputs=True, **{param: -1})
@@ -888,6 +895,7 @@ def test_option_feerate(self):
self.log.info("- raises RPC error if both fee_rate and estimate_mode are passed")
assert_raises_rpc_error(-8, "Cannot specify both estimate_mode and fee_rate",
node.fundrawtransaction, rawtx, fee_rate=1, estimate_mode="economical", add_inputs=True)
+ self.connect_nodes(0, 3)
def test_address_reuse(self):
"""Test no address reuse occurs."""
@@ -1390,8 +1398,10 @@ def test_22670(self):
# Make sure the default wallet will not be loaded when restarted with a high minrelaytxfee
self.nodes[0].unloadwallet(self.default_wallet_name, False)
feerate = Decimal("0.1")
- self.restart_node(0, [f"-minrelaytxfee={feerate}", "-discardfee=0"]) # Set high minrelayfee, set discardfee to 0 for easier calculation
-
+ # Set a high -minrelaytxfee and set -discardfee to 0 for easier calculation.
+ # Set -maxfeerate higher than minrelaytxfee to allow high fee rate txs to be
+ # created and broadcasted.
+ self.restart_node(0, [f"-minrelaytxfee={feerate}", "-discardfee=0", "-maxfeerate=1"])
self.nodes[0].loadwallet(self.default_wallet_name, True)
funds = self.nodes[0].get_wallet_rpc(self.default_wallet_name)
self.nodes[0].createwallet(wallet_name="tester")
@@ -1434,9 +1444,7 @@ def do_fund_send(target):
do_fund_send(upper_bound)
self.restart_node(0)
- self.connect_nodes(0, 1)
- self.connect_nodes(0, 2)
- self.connect_nodes(0, 3)
+ self.reconnect_nodes()
def test_feerate_rounding(self):
self.log.info("Test that rounding of GetFee does not result in an assertion")
### test/functional/wallet_send.py
@@ -195,6 +195,35 @@ def test_send(self, from_wallet, to_wallet=None, amount=None, data=None,
return res
+ def test_maxfeerate(self):
+ self.log.info("Test that -maxfeerate below -minrelaytxfee is rejected.")
+ # Unload the default wallet so its auto-load doesn't abort startup once
+ # -maxfeerate drops below -minrelaytxfee.
+ self.nodes[0].unloadwallet(self.default_wallet_name, load_on_startup=False)
+ self.restart_node(0, extra_args=['-minrelaytxfee=0.00020000', '-maxfeerate=0.0001'])
+ assert_raises_rpc_error(-4,
+ "Invalid amount for -maxfeerate=<amount>: '0.0001' "
+ "(must be at least the minrelay fee of 0.00020000 BTC/kvB "
+ "to prevent stuck transactions)",
+ self.nodes[0].createwallet, "w_maxfeerate_below_minrelay")
+ self.restart_node(0)
+ self.nodes[0].loadwallet(self.default_wallet_name, load_on_startup=True)
+
+ self.log.info("test -maxfeerate enforcement on wallet transactions.")
+ # Default maxfeerate is 10,000 sat/vB
+ # Wallet will reject all transactions with fee rate above 10,000 sat/vB.
+ assert_raises_rpc_error(-6, "Fee rate exceeds maximum configured by user (maxfeerate)",
+ self.nodes[0].sendtoaddress, address=self.nodes[0].getnewaddress(), amount=1, fee_rate=10001)
+
+ self.nodes[0].sendtoaddress(self.nodes[0].getnewaddress(), amount=1, fee_rate=9900)
+ self.restart_node(0, extra_args=['-maxfeerate=0.00010'])
+ assert_raises_rpc_error(-6, "Fee rate exceeds maximum configured by user (maxfeerate)",
+ self.nodes[0].sendtoaddress, address=self.nodes[0].getnewaddress(), amount=1, fee_rate=11)
+ self.nodes[0].sendtoaddress(self.nodes[0].getnewaddress(), amount=1, fee_rate=9)
+
+ self.restart_node(0, extra_args=['-maxfeerate=0.00001009'])
+ self.nodes[0].sendtoaddress(self.nodes[0].getnewaddress(), amount=1, fee_rate=Decimal("1.009"))
+
def run_test(self):
self.log.info("Setup wallets...")
# w0 is a wallet with coinbase rewards
@@ -528,6 +557,8 @@ def run_test(self):
# Check tx creation size limits
self.test_weight_limits()
+ self.test_maxfeerate()
+
def test_weight_limits(self):
self.log.info("Test weight limits")
### test/functional/wallet_sendall.py
@@ -431,6 +431,43 @@ def sendall_does_ancestor_aware_funding(self):
assert_greater_than(higher_parent_feerate_amount, lower_parent_feerate_amount)
+ def reload_wallets(self):
+ loaded = self.nodes[0].listwallets()
+ for name in ["activewallet", self.default_wallet_name]:
+ if name not in loaded:
+ self.nodes[0].loadwallet(name)
+ self.wallet = self.nodes[0].get_wallet_rpc("activewallet")
+ self.def_wallet = self.nodes[0].get_wallet_rpc(self.default_wallet_name)
+
+ def assert_sendall_bump_fee_over_limit(self, *, fee_rate, error):
+ # Fund an unconfirmed parent below the spend feerate, so spending it needs a bump fee that,
+ # added to the child's own fee, pushes the total past the configured limit.
+ self.def_wallet.sendtoaddress(address=self.wallet.getnewaddress(), amount=17, fee_rate=20)
+ self.wallet.syncwithvalidationinterfacequeue()
+ unspent = self.wallet.listunspent(minconf=0)[0]
+ assert_equal(self.wallet.gettransaction(unspent["txid"])["confirmations"], 0)
+ assert_raises_rpc_error(-4, error, self.wallet.sendall,
+ recipients=[self.remainder_target], inputs=[unspent], fee_rate=fee_rate)
+ self.generate(self.nodes[0], 1)
+
+ @cleanup
+ def sendall_fails_when_bump_fees_exceed_maxfeerate(self):
+ self.log.info("Test that sendall rejects a tx whose ancestor bump fees push the fee rate above -maxfeerate")
+ # fee_rate equals the default -maxfeerate (10000 sat/vB).
+ self.assert_sendall_bump_fee_over_limit(fee_rate=10000, error="Fee rate exceeds maximum configured by user (maxfeerate)")
+
+ @cleanup
+ def sendall_fails_when_bump_fees_exceed_maxtxfee(self):
+ self.log.info("Test that sendall rejects a tx whose ancestor bump fees push the total fee above -maxtxfee")
+ self.restart_node(0, extra_args=["-maxtxfee=0.0005"])
+ self.reload_wallets()
+ # Child fee stays under -maxtxfee; the bump fee pushes the total above it.
+ self.assert_sendall_bump_fee_over_limit(fee_rate=400, error="Fee exceeds maximum configured by user (maxtxfee)")
+ self.restart_node(0)
+ self.reload_wallets()
+ self.restart_node(0)
+ self.reload_wallets()
+
@cleanup
def sendall_anti_fee_sniping(self):
self.log.info("Testing sendall does anti-fee-sniping when locktime is not specified")
@@ -543,6 +580,12 @@ def run_test(self):
# Sendall spends unconfirmed inputs if they are specified
self.sendall_spends_unconfirmed_inputs_if_specified()
+ # Sendall rejects a tx whose ancestor bump fees push the fee rate above -maxfeerate
+ self.sendall_fails_when_bump_fees_exceed_maxfeerate()
+
+ # Sendall rejects a tx whose ancestor bump fees push the total fee above -maxtxfee
+ self.sendall_fails_when_bump_fees_exceed_maxtxfee()
+
# Sendall does ancestor aware funding when spending an unconfirmed UTXO
self.sendall_does_ancestor_aware_funding()
Why this scored 28/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.