From 19b32a2e18017c3bf8ea12d25d48b17347f490b6 Mon Sep 17 00:00:00 2001 From: Hao Xu Date: Sun, 7 Jun 2026 15:42:49 +0800 Subject: [PATCH 1/5] fuzz: reset the mockable steady clock between iterations CheckGlobalsImpl's constructor runs at the start of every fuzz iteration and already resets the global RNG flags and the mockable NodeClock via SetMockTime(0s), but it never reset the mockable steady clock. A value written to g_mock_steady_time by one input therefore leaked into the next one. For example, FuzzedSock's constructor calls SetMockTime(INITIAL_MOCK_TIME) and never clears it, so the mocked steady time stays set for all subsequent iterations. Reset MockableSteadyClock symmetrically with NodeClock so each input starts from an unmocked steady clock. This also brings the steady clock under the same discipline as the system clock: a target that reads MockableSteadyClock::now() without first mocking it is now caught by the existing g_used_system_time check instead of silently reusing a leaked value. --- src/test/fuzz/util/check_globals.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/fuzz/util/check_globals.cpp b/src/test/fuzz/util/check_globals.cpp index ca3111534d3b..87d96ebd9726 100644 --- a/src/test/fuzz/util/check_globals.cpp +++ b/src/test/fuzz/util/check_globals.cpp @@ -19,6 +19,7 @@ struct CheckGlobalsImpl { g_seeded_g_prng_zero = false; g_used_system_time = false; SetMockTime(0s); + MockableSteadyClock::ClearMockTime(); } ~CheckGlobalsImpl() { From 5c46d0c5323ae286be25d899821fd947f22e501a Mon Sep 17 00:00:00 2001 From: Biel Date: Thu, 14 May 2026 17:01:00 +0200 Subject: [PATCH 2/5] Do not allow bumpfee to backdate the replacement transaction lockitme This changes change the behavior of bumpfee so when used, the replacement transaction doesn't have an older lockitme than the original transaction. Now the replacement transaction will have either the block_height as the locktime or the locktime of the original transaction. --- src/wallet/coincontrol.h | 2 ++ src/wallet/feebumper.cpp | 4 ++++ src/wallet/spend.cpp | 14 +++++++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/wallet/coincontrol.h b/src/wallet/coincontrol.h index f329de688a71..08d7351c0b83 100644 --- a/src/wallet/coincontrol.h +++ b/src/wallet/coincontrol.h @@ -116,6 +116,8 @@ class CCoinControl uint32_t m_version = DEFAULT_WALLET_TX_VERSION; //! Locktime std::optional m_locktime; + //! Save the previous locktime for replacements + std::optional m_previous_locktime; //! Caps weight of resulting tx std::optional m_max_tx_weight{std::nullopt}; diff --git a/src/wallet/feebumper.cpp b/src/wallet/feebumper.cpp index 1a402c851faf..9c7180dcb5b2 100644 --- a/src/wallet/feebumper.cpp +++ b/src/wallet/feebumper.cpp @@ -312,6 +312,10 @@ Result CreateRateBumpTransaction(CWallet& wallet, const Txid& txid, const CCoinC // We cannot source new unconfirmed inputs(bip125 rule 2) new_coin_control.m_min_depth = 1; + // If no locktime is set, we save the previous one for anti fee sniping + if (!new_coin_control.m_locktime){ + new_coin_control.m_previous_locktime = wtx.tx->nLockTime; + } auto res = CreateTransaction(wallet, recipients, /*change_pos=*/std::nullopt, new_coin_control, false); if (!res) { diff --git a/src/wallet/spend.cpp b/src/wallet/spend.cpp index dba7b8829595..f703166542f3 100644 --- a/src/wallet/spend.cpp +++ b/src/wallet/spend.cpp @@ -1016,6 +1016,7 @@ void DiscourageFeeSniping(CMutableTransaction& tx, FastRandomContext& rng_fast, // now we ensure code won't be written that makes assumptions about // nLockTime that preclude a fix later. if (IsCurrentForAntiFeeSniping(chain, block_hash)) { + uint32_t previous_locktime = tx.nLockTime; tx.nLockTime = block_height; // Secondly occasionally randomly pick a nLockTime even further back, so @@ -1023,7 +1024,15 @@ void DiscourageFeeSniping(CMutableTransaction& tx, FastRandomContext& rng_fast, // e.g. high-latency mix networks and some CoinJoin implementations, have // better privacy. if (rng_fast.randrange(10) == 0) { - tx.nLockTime = std::max(0, int(tx.nLockTime) - int(rng_fast.randrange(100))); + // If a previous lockitme is passed (like in the bump fee case), the + // backdating is limited betwen the current height and the previous lockitme + if (previous_locktime > 0){ + // To avoid issues with the randrange, locktime_range cannot be 0 + int locktime_range = std::max(1, std::min(100, int(block_height - previous_locktime))); + tx.nLockTime = std::max(int(previous_locktime), int(tx.nLockTime) - int(rng_fast.randrange(locktime_range))); + }else{ + tx.nLockTime = std::max(0, int(tx.nLockTime) - int(rng_fast.randrange(100))); + } } } else { // If our chain is lagging behind, we can't discourage fee sniping nor help @@ -1317,10 +1326,13 @@ static util::Result CreateTransactionInternal( txNew.vin.back().scriptWitness = *scripts.second; } } + // txNew.nLockTime = 0; if (coin_control.m_locktime) { txNew.nLockTime = coin_control.m_locktime.value(); // If we have a locktime set, we can't use anti-fee-sniping use_anti_fee_sniping = false; + } else if (coin_control.m_previous_locktime.has_value()) { + txNew.nLockTime = coin_control.m_previous_locktime.value(); } if (use_anti_fee_sniping) { DiscourageFeeSniping(txNew, rng_fast, wallet.chain(), wallet.GetLastBlockHash(), wallet.GetLastBlockHeight()); From 4fd9d294c685f056d08d1845f63280c751846cec Mon Sep 17 00:00:00 2001 From: Biel Date: Fri, 15 May 2026 18:41:34 +0200 Subject: [PATCH 3/5] Test bumpfee does not backdate the locktime Test to check that the replacement transaction from bumpfee does not have an older locktime than the original transaction- --- test/functional/wallet_bumpfee.py | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/functional/wallet_bumpfee.py b/test/functional/wallet_bumpfee.py index b07425c1a26f..4749944df516 100755 --- a/test/functional/wallet_bumpfee.py +++ b/test/functional/wallet_bumpfee.py @@ -30,6 +30,7 @@ assert_raises_rpc_error, get_fee, find_vout_for_address, + assert_greater_than_or_equal ) from test_framework.wallet import MiniWallet @@ -114,6 +115,8 @@ def run_test(self): # Context independent tests test_feerate_checks_replaced_outputs(self, rbf_node, peer_node) test_bumpfee_with_feerate_ignores_walletincrementalrelayfee(self, rbf_node, peer_node) + test_bumpfee_dont_backdate(self, rbf_node) + test_bumpfee_timebased_locktime(self, rbf_node) def test_invalid_parameters(self, rbf_node, peer_node, dest_address): self.log.info('Test invalid parameters') @@ -828,6 +831,37 @@ def test_bumpfee_with_feerate_ignores_walletincrementalrelayfee(self, rbf_node, rbf_node.bumpfee(tx["txid"], {"fee_rate": 2.1}) self.clear_mempool() +def test_bumpfee_dont_backdate(self, rbf_node): + self.log.info('Test to check bumpfee do not backdate the locktime') + + rbf_node.createwallet("backdating_rbf") + wallet = rbf_node.get_wallet_rpc("backdating_rbf") + self.generatetoaddress(rbf_node, 101, wallet.getnewaddress()) + + current_height = rbf_node.getblockchaininfo()["blocks"] + # The original tx has an older locktime so we can differentiate when the replacement backdates + original_locktime = current_height - 50 + replaced_locktime = current_height + + # Exti the loop when the locktime backdates + while current_height == replaced_locktime: + # Original transaction with locktime at the current height + tx = wallet.send(outputs={wallet.getnewaddress(): 9}, fee_rate=2, locktime=original_locktime) + + # Replacement with higher fee_rate + change_addr = get_change_address(tx["txid"], wallet)[0] + bumped = wallet.bumpfee(txid=tx["txid"], options={"fee_rate":5, "outputs": [{change_addr: 10}]}) + + replaced_locktime = rbf_node.getrawtransaction(bumped["txid"],True)["locktime"] + + # Clear mempool to create another transaction to replace + self.clear_mempool() + + # Even when backdating, the replaced tx must always have locktime >= than the original tx + assert_greater_than_or_equal(replaced_locktime, original_locktime) + + wallet.unloadwallet() + if __name__ == "__main__": BumpFeeTest(__file__).main() From c3e0500919d3a414285736e2e2d46d28a979ac39 Mon Sep 17 00:00:00 2001 From: Biel Date: Fri, 26 Jun 2026 10:51:39 +0200 Subject: [PATCH 4/5] Do not anti fee snipe in RBF when locktime is time-based If when replacing a transaction, the original has a time-based locktime, do not anti-fee snipe as the locktime type would change, making the anti-fee sniping very identifiable. --- src/wallet/spend.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/wallet/spend.cpp b/src/wallet/spend.cpp index f703166542f3..c17d09e814c9 100644 --- a/src/wallet/spend.cpp +++ b/src/wallet/spend.cpp @@ -1332,6 +1332,11 @@ static util::Result CreateTransactionInternal( // If we have a locktime set, we can't use anti-fee-sniping use_anti_fee_sniping = false; } else if (coin_control.m_previous_locktime.has_value()) { + // If the previous locktime is time-based, we keep the same locktime, + //and we don't apply fee sniping + if (coin_control.m_previous_locktime >= LOCKTIME_THRESHOLD){ + use_anti_fee_sniping = false; + } txNew.nLockTime = coin_control.m_previous_locktime.value(); } if (use_anti_fee_sniping) { From 2ce5139d7410e64ecd99f475449a7a6efc0e3ba2 Mon Sep 17 00:00:00 2001 From: Biel Date: Fri, 26 Jun 2026 10:55:58 +0200 Subject: [PATCH 5/5] Test bumpfee with time-based locktime --- test/functional/wallet_bumpfee.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/functional/wallet_bumpfee.py b/test/functional/wallet_bumpfee.py index 4749944df516..251ff276d9d3 100755 --- a/test/functional/wallet_bumpfee.py +++ b/test/functional/wallet_bumpfee.py @@ -862,6 +862,23 @@ def test_bumpfee_dont_backdate(self, rbf_node): wallet.unloadwallet() + def test_bumpfee_timebased_locktime(self, rbf_node): + self.log.info(f"Test to check bumpfee don't anti fee snipe if original tx had time-based locktime") + + rbf_node.createwallet("rbf") + wallet = rbf_node.get_wallet_rpc("rbf") + self.generatetoaddress(rbf_node, 101, wallet.getnewaddress()) + + original_locktime = 500000000 + tx = wallet.send(outputs={wallet.getnewaddress(): 9}, fee_rate=2, locktime=original_locktime) + hex_tx = rbf_node.getrawtransaction(tx["txid"]) + + # Replacement with higher fee_rate + change_addr = get_change_address(tx["txid"], wallet)[0] + bumped = wallet.bumpfee(txid=tx["txid"], options={"fee_rate":5, "outputs": [{change_addr: 10}]}) + + locktime = rbf_node.getrawtransaction(bumped["txid"],True)["locktime"] + assert_equal(original_locktime, locktime) if __name__ == "__main__": BumpFeeTest(__file__).main()