wallet: don’t back-date locktime below input locktime #34480

pull danielabrozzoni wants to merge 6 commits into bitcoin:master from danielabrozzoni:issue/26527-dont-backtime-nlocktime-unconf changing 7 files +166 −8
  1. danielabrozzoni commented at 12:47 pm on February 2, 2026: member

    Fixes #26527

    The Bitcoin Core wallet implements an anti-fee-sniping mechanism that sets a transaction’s nLockTime to the current block height, and occasionally to an earlier height.

    Previously, when creating a transaction, the wallet could choose an nLockTime that was lower than the nLockTime of one of its inputs. This creates an unrealistic “transaction signed earlier but only broadcast now” scenario and may act as a wallet fingerprint.

    This PR fixes the issue by adding a min_allowed_locktime parameter to DiscourageFeeSniping. When creating a transaction, the wallet now sets this minimum to the highest locktime among all parent transactions. This ensures that while the locktime can still be backdated for anti-fee-sniping, it won’t go below any input’s locktime.

    DiscourageFeeSniping is invoked by the following RPCs:

    • sendall
    • send
    • sendtoaddress
    • sendmany
    • fundrawtransaction
    • walletcreatefundedpsbt

    Tests are added for sendall, send, sendtoaddress and sendmany.

    fundrawtransaction and walletcreatefundedpsbt can’t hit this edge case, because they don’t use the anti-fee-sniping logic and instead always set nLockTime to 0 unless explicitly provided by the user.

  2. DrahtBot added the label Wallet on Feb 2, 2026
  3. DrahtBot commented at 12:48 pm on February 2, 2026: contributor

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    Reviews

    See the guideline for information on the review process. A summary of reviews will appear here.

  4. danielabrozzoni commented at 12:52 pm on February 2, 2026: member
    @ishaanam: I put you as a co-author for the first commit, because I started working from your code here #26902#pullrequestreview-1522310111 :)
  5. in src/wallet/spend.cpp:1301 in c4325985a0
    1297+        // Find the highest nLockTime of all unconfirmed inputs so that
    1298+        // this transaction does not use a lower value.
    1299+        // Setting a lower nLockTime would make the "signed earlier but broadcast later"
    1300+        // explanation unrealistic, and may act as a wallet fingerprint.
    1301+        unsigned int max_unconfirmed_input_locktime{0}; // If there aren't any unconfirmed inputs, the minimum locktime is 0
    1302+        for (const auto& coin: selected_coins) {
    


    achow101 commented at 10:03 pm on February 2, 2026:

    In c4325985a0a6f82d482f9c1f9c0276336b2476c8 “wallet: don’t back-date locktime below unconfirmed input locktime”

    nit:

    0        for (const auto& coin : selected_coins) {
    
  6. in test/functional/wallet_send.py:572 in d4b45ac6fb
    563@@ -563,6 +564,25 @@ def test_weight_limits(self):
    564 
    565         self.nodes[1].unloadwallet("test_weight_limits")
    566 
    567+    def test_unconfirmed_input_fee_sniping(self):
    568+        self.log.info("Test that send sets the transaction locktime no lower than any unconfirmed input locktime")
    569+        self.nodes[1].createwallet("test_fee_sniping")
    570+        # Create parent tx with anti-fee-sniping nlocktime
    571+        wallet = self.nodes[1].get_wallet_rpc("test_fee_sniping")
    572+        unconfirmed_parent_txid = self.nodes[0].sendtoaddress(wallet.getnewaddress(), 10)
    


    achow101 commented at 10:08 pm on February 2, 2026:

    In d4b45ac6fb09ac85579724497ec9ba639235fe0a “test: check send RPC locktime is not below unconfirmed input locktime”

    This relies on the anti-fee-sniping to randomly backdate the locktime. I think it would be better if this could set the locktime to a specific value that would be in range so that the range is not often just a single value.

  7. in test/functional/wallet_send.py:579 in d4b45ac6fb
    574+        # Parent nlocktime should be within 100 blocks of tip
    575+        assert_greater_than_or_equal(unconfirmed_parent_nlocktime, self.nodes[0].getblockcount() - 100)
    576+        self.sync_mempools()
    577+        # Create child tx spending the unconfirmed parent
    578+        utxos = wallet.listunspent(0, 0)
    579+        child_txid = wallet.send([{wallet.getnewaddress(): 8}], inputs=utxos)["txid"]
    


    achow101 commented at 10:09 pm on February 2, 2026:

    In d4b45ac6fb09ac85579724497ec9ba639235fe0a “test: check send RPC locktime is not below unconfirmed input locktime”

    Since backdating anti-fee-sniping is random, we should do this multiple times to be reasonably sure that we get a backdated nlocktime.

  8. achow101 commented at 10:12 pm on February 2, 2026: member

    The sendtoaddress and sendmany RPCs don’t spend unconfirmed inputs

    They can spend unconfirmed change, and I would prefer for these to be tested as well since the calling of DiscourageFeeSniping is different between these RPCs and send and sendall.

  9. wallet: don't back-date locktime below input locktime
    When creating a transaction with anti fee sniping enabled, the wallet
    could previously select an nLockTime that was lower than the nLockTime
    of its input transactions. This can break the "transaction signed earlier
    but broadcast later" explanation and may act as a wallet fingerprint.
    
    This is fixed by constraining the anti fee sniping logic to never select
    an nLockTime lower than the maximum nLockTime of the wallet-owned input
    transactions. The minimum allowed value is passed to
    DiscourageFeeSniping().
    
    Co-Authored-By: ishaanam <ishaana.misra@gmail.com>
    8c537a1a26
  10. test: assert anti-fee-sniping locktime is within 100 blocks
    The previous assertion only verified that nLockTime was non-zero.
    Tighten the check to ensure the locktime is within the allowed range,
    up to 100 blocks in the past.
    8299967664
  11. test: check send respects input locktimes with anti fee sniping
    Add a test to verify that when the anti fee sniping logic randomly
    backdates a transaction locktime, the send RPC never selects a value
    lower than the nLockTime of its input transactions.
    4033a5ccf9
  12. test: check sendall respects input locktimes with anti fee sniping
    Add a test to verify that when the anti fee sniping logic randomly
    backdates a transaction locktime, the send RPC never selects a value
    lower than the nLockTime of its input transactions.
    788cfa42be
  13. test: check sendtoaddress respects input locktimes with anti fee sniping
    Add a test to verify that when the anti fee sniping logic randomly
    backdates a transaction locktime, the send RPC never selects a value
    lower than the nLockTime of its input transactions.
    b0f6e96eff
  14. test: check sendmany respects input locktimes with anti fee sniping
    Add a test to verify that when the anti fee sniping logic randomly
    backdates a transaction locktime, the send RPC never selects a value
    lower than the nLockTime of its input transactions.
    c3e79dffb0
  15. danielabrozzoni force-pushed on Feb 5, 2026
  16. danielabrozzoni renamed this:
    wallet: don't back-date locktime below unconfirmed input locktime
    wallet: don't back-date locktime below input locktime
    on Feb 5, 2026
  17. danielabrozzoni commented at 10:17 am on February 5, 2026: member

    Thanks for your review Ava! I picked up your suggestions, and the idea in #26527 (comment).

    Changes since last push:

    • Now we avoid backdating the nlocktime below any input nLocktime, not just unconfirmed inputs.
    • I added tests for sendtoaddress and sendmany. These use confirmed parent transactions instead of unconfirmed change, which was easier to implement and still tests the same behavior.
    • In the tests, I explicitly set the parent nLockTime and retry creating a child transaction until I get one with a backdated nLockTime (nLockTime != tip_height).

    One concern I have, which I just realized: since we cannot know the nLockTime for external inputs, we might still end up backdating below those inputs’ nLockTime. This could be problematic, since it may leak some information about which inputs are wallet-owned. Is this acceptable?

    I thought about a couple of ways to mitigate this but could not implement either. Let me know if I missed something:

    • Getting external unconfirmed inputs from the mempool. Unfortunately, I could use wallet.chain to check whether a tx is in the mempool (isInMempool), but couldn’t retrieve the whole transaction
    • Getting external inputs from the txindex, if enabled. Unfortunately I could not access g_txindex from the wallet code.

    Otherwise, we could disallow backdating if there’s at least one external input, by setting min_allowed_locktime to the tip height.

  18. in test/functional/wallet_send.py:585 in 4033a5ccf9
    580+        child_nlocktime = tip_height
    581+        # We keep track of the amount to send, so that at each loop we send a bit less, paying more in fees
    582+        # and replacing the previous transaction
    583+        amount_to_send = 99
    584+        while child_nlocktime == tip_height:
    585+            child_txid = wallet.send([{wallet.getnewaddress(): amount_to_send}], inputs=utxos)["txid"]
    


    achow101 commented at 0:08 am on February 6, 2026:

    In 4033a5ccf924a22bb9236f67052dc05f1b14c446 “test: check send respects input locktimes with anti fee sniping”

    Setting add_to_wallet=False will prevent the transaction from being added to the wallet or broadcast. That will allow this loop to run without needing to change the amount to send.

    Same for the sendall test.


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-02-09 18:13 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me