{
 "number": 35026,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/35026",
 "title": "mempool: recalculate stale BIP68 lockpoints with mempool parents in removeForReorg",
 "author": "javierpmateos",
 "author_association": "FIRST_TIME_CONTRIBUTOR",
 "created_at": "2026-04-07T20:13:33Z",
 "updated_at": "2026-09-07T03:57:15Z",
 "age_days": 162,
 "draft": false,
 "labels": [
  "Mempool"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "ea2d638bb94542290578bd65c7c16f2849c56071",
 "head_ref": "fix-bip68-stale-lockpoints-clean",
 "head_repo": "javierpmateos/bitcoin",
 "head_history": [
  {
   "t": "2026-04-07T21:10:33Z",
   "sha": "d7fec00f1799e953e34a8ee72462a725c6d3467d"
  },
  {
   "t": "2026-04-07T23:14:03Z",
   "sha": "c67f4de9b429782c161338e176ac5e9b13fb5246"
  },
  {
   "t": "2026-04-07T23:45:52Z",
   "sha": "a1f29608cc9ac252cca355853a032806e9685f0a"
  },
  {
   "t": "2026-04-08T01:59:32Z",
   "sha": "d3ba673a2ba60398969c2027baa7a18f005244ee"
  },
  {
   "t": "2026-04-08T10:11:42Z",
   "sha": "5e23b1a9b12ae5581f27337b021bcd16e4ebc6db"
  },
  {
   "t": "2026-04-08T10:17:42Z",
   "sha": "9dc0642ac58fd75993d8e5bfa5cecbbb0d5397a0"
  },
  {
   "t": "2026-05-10T17:40:26Z",
   "sha": "c281b1ea0b17a969b29f12665c12e1b2f8aab664"
  },
  {
   "t": "2026-07-16T09:24:41Z",
   "sha": "3813fc8c9d6a70ce2e22fa2295e2ff95837ba67b"
  },
  {
   "t": "2026-07-20T19:08:57Z",
   "sha": "c71aa2db62c98e8987dbb4b63bcc6a45da273cc5"
  },
  {
   "t": "2026-08-20T22:29:11Z",
   "sha": "8e416e0598a5e771fdbce982d18cc7e2930c4fed"
  },
  {
   "t": "2026-08-23T15:55:35Z",
   "sha": "ea2d638bb94542290578bd65c7c16f2849c56071"
  }
 ],
 "additions": 137,
 "deletions": 5,
 "changed_files": 3,
 "commit_count": 1,
 "size_bucket": "M",
 "mergeable_state": "clean",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "ack": [
     {
      "login": "Bicaru20",
      "url": "https://github.com/bitcoin/bitcoin/pull/35026#issuecomment-5393343601"
     }
    ],
    "stale_ack": [
     {
      "login": "ismaelsadeeq",
      "url": "https://github.com/bitcoin/bitcoin/pull/35026#pullrequestreview-4734967089"
     }
    ]
   },
   "conflicts": []
  }
 },
 "acks_parsed": {
  "Bicaru20": {
   "kind": "ack",
   "hash": "ea2d638bb94542290578bd65c7c16f2849c56071",
   "t": "2026-08-24T09:34:46Z",
   "stale": false
  },
  "ismaelsadeeq": {
   "kind": "ack",
   "hash": "3813fc8c9d6a70ce2e22fa2295e2ff95837ba67b",
   "t": "2026-07-20T12:39:14Z",
   "stale": true
  }
 },
 "acks_tally": {
  "ack": 1,
  "stale_ack": 1,
  "concept_ack": 0,
  "approach_ack": 0,
  "nack": 0,
  "concept_nack": 0,
  "approach_nack": 0
 },
 "reviews": {
  "approved": 1,
  "changes_requested": 0,
  "distinct_reviewers": [
   "Bicaru20",
   "Bortlesboat",
   "instagibbs",
   "ismaelsadeeq",
   "sedited"
  ]
 },
 "signals": {
  "needs_rebase": false,
  "ci_failed": false,
  "mergeable_state": "clean",
  "last_author_activity": "2026-08-23T16:02:53Z",
  "last_reviewer_activity": "2026-09-06T11:23:42Z",
  "last_reviewer": "sedited",
  "author_silent_days": 25,
  "waiting_on_author_days": 11,
  "days_since_update": 10
 },
 "refs": {
  "mentioned": [
   33504,
   35007
  ],
  "depends_on": [],
  "fixes": [
   35007
  ],
  "linked_issues": [
   {
    "number": 35007,
    "type": "issue",
    "state": "open",
    "merged": false,
    "merged_at": null,
    "title": "removeForReorg incorrectly evicts valid BIP68 transactions with mempool parents after invalidateblock"
   }
  ],
  "references": [
   {
    "number": 35007,
    "type": "issue",
    "state": "open",
    "merged": false,
    "merged_at": null,
    "title": "removeForReorg incorrectly evicts valid BIP68 transactions with mempool parents after invalidateblock"
   },
   {
    "number": 33504,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2025-10-02",
    "title": "Mempool: Do not enforce TRUC checks on reorg"
   }
  ],
  "conflicts": []
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [
  "src/validation.cpp",
  "test/functional/mempool_reorg_bip68_stale_lockpoints.py"
 ],
 "body": "When a transaction with `nSequence=0` (BIP68 relative locktime 0) enters\nthe mempool with an unconfirmed parent, `CalculatePrevHeights` assigns\n`nCoinHeight=tip+1` and the resulting LockPoints are cached with\n`maxInputBlock=genesis`. After `invalidateblock` lowers the tip,\n`TestLockPointValidity` returns true for genesis (always on chain),\nso the stale cached lockpoints are reused. `CheckSequenceLocksAtTip`\nthen incorrectly determines the lock is not satisfied and removes\nthe transaction along with all its descendants.\n\nThe fix detects the genesis sentinel (`maxInputBlock->nHeight==0` with\n`lp.height>0`) in `filter_final_and_mature`, which indicates lockpoints\ncomputed with mempool parent inputs, and forces fresh recalculation via\n`CalculateLockPointsAtTip` instead of using the stale cache.\n\nAdded `mempool_reorg_bip68_stale_lockpoints.py` which verifies that\nboth BIP68-disabled and BIP68-enabled children with mempool parents\nsurvive a multi-block reorg via `invalidateblock`.\n\nReproducer for unpatched nodes:\nhttps://gist.github.com/javierpmateos/c55d365973adbf488a852dc5e0b77dec\n\nSame class of issue fixed for TRUC in #33504.\n\nCloses #35007",
 "commits": [
  {
   "sha": "ea2d638bb94542290578bd65c7c16f2849c56071",
   "date": "2026-08-23T15:55:21Z",
   "message": "mempool: recalculate stale BIP68 lockpoints in removeForReorg\n\nBIP68 transactions entering the mempool with unconfirmed parents cache\nLockPoints relative to the current tip (nCoinHeight=tip+1). After\ninvalidateblock lowers the tip, these cached lockpoints are stale, but\nTestLockPointValidity may still return true, so filter_final_and_mature\nevicts the transaction and its descendants based on the stale cache.\n\nRecalculate via CalculateLockPointsAtTip whenever the cached validity\ncheck or the sequence-lock check fails, and only evict if the fresh\nresult also fails. This handles all-unconfirmed, mixed, and time-based\nrelative locktimes uniformly.\n\nThe functional test covers all four cases: BIP68-disabled, height-based\nwith unconfirmed parents, mixed confirmed/unconfirmed inputs, and a\ntime-based relative locktime where the reorg lowers the median time past.\n\nCo-authored-by: Abubakar Sadiq Ismail <abubakarsadiqismail@proton.me>\nCo-authored-by: Bicaru20 <bicaru2@gmail.com>\n\nCloses #35007"
  }
 ],
 "timeline": [
  {
   "t": "2026-04-07T21:10:33Z",
   "kind": "force_push",
   "who": "javierpmateos",
   "commit": "d7fec00f1799e953e34a8ee72462a725c6d3467d"
  },
  {
   "t": "2026-04-07T23:14:03Z",
   "kind": "force_push",
   "who": "javierpmateos",
   "commit": "c67f4de9b429782c161338e176ac5e9b13fb5246"
  },
  {
   "t": "2026-04-07T23:45:52Z",
   "kind": "force_push",
   "who": "javierpmateos",
   "commit": "a1f29608cc9ac252cca355853a032806e9685f0a"
  },
  {
   "t": "2026-04-08T01:59:32Z",
   "kind": "force_push",
   "who": "javierpmateos",
   "commit": "d3ba673a2ba60398969c2027baa7a18f005244ee"
  },
  {
   "t": "2026-04-08T10:11:42Z",
   "kind": "force_push",
   "who": "javierpmateos",
   "commit": "5e23b1a9b12ae5581f27337b021bcd16e4ebc6db"
  },
  {
   "t": "2026-04-08T10:17:42Z",
   "kind": "force_push",
   "who": "javierpmateos",
   "commit": "9dc0642ac58fd75993d8e5bfa5cecbbb0d5397a0"
  },
  {
   "t": "2026-04-08T13:15:46Z",
   "kind": "comment",
   "who": "instagibbs",
   "assoc": "MEMBER",
   "text": "thanks for the PR. From my recollection this code is pretty complicated sadly. Will take a look soon hopefully to see if I can grasp it"
  },
  {
   "t": "2026-05-03T12:39:56Z",
   "kind": "review_comment",
   "who": "Bicaru20",
   "assoc": "CONTRIBUTOR",
   "path": "src/validation.cpp",
   "commit": "c281b1ea0b17a969b29f12665c12e1b2f8aab664",
   "in_reply_to": null,
   "text": "Since we already call `TestLockPointValidity`, maybe it would be better if this condition:\n`!(lp.maxInputBlock && lp.maxInputBlock->nHeight == 0 && lp.height > 0))`\nis included inside of the `TestLockPointValidity`. We don't have to pass any extra parameters and that way the condition on the `if` is cleaner."
  },
  {
   "t": "2026-05-03T12:42:00Z",
   "kind": "review_comment",
   "who": "Bicaru20",
   "assoc": "CONTRIBUTOR",
   "path": "test/functional/mempool_reorg_bip68_stale_lockpoints.py",
   "commit": "9dc0642ac58fd75993d8e5bfa5cecbbb0d5397a0",
   "in_reply_to": null,
   "text": "Maybe use `assert_equal`\n\n```suggestion\n        assert_equal(node.getblockcount(), H - 1)\n```"
  },
  {
   "t": "2026-05-03T19:39:09Z",
   "kind": "review_comment",
   "who": "Bicaru20",
   "assoc": "CONTRIBUTOR",
   "path": "test/functional/mempool_reorg_bip68_stale_lockpoints.py",
   "commit": "9dc0642ac58fd75993d8e5bfa5cecbbb0d5397a0",
   "in_reply_to": null,
   "text": "I'm not getting why do we need this. You create a block and immediately invalidate it. `block_setup` is needed to reproduce the bug, since the `LockPoints` were calculated with this blockchain height. However, once that is done, why generate another block? If I am not mistaken this last block does not affect at all on the calculation.\n\nI've tried the test without this block and the behavior is the same."
  },
  {
   "t": "2026-05-10T17:40:26Z",
   "kind": "force_push",
   "who": "javierpmateos",
   "commit": "c281b1ea0b17a969b29f12665c12e1b2f8aab664"
  },
  {
   "t": "2026-05-10T17:42:10Z",
   "kind": "review_comment",
   "who": "javierpmateos",
   "assoc": "NONE",
   "path": "src/validation.cpp",
   "commit": "c281b1ea0b17a969b29f12665c12e1b2f8aab664",
   "in_reply_to": 3178122102,
   "text": "Thanks for the suggestion. I attempted this refactor but it\nbreaks the invariant asserted in CTxMemPool::removeForReorg()\nat txmempool.cpp:390:\n\n    assert(TestLockPointValidity(chain, it->GetLockPoints()));\n\nWhen filter_final_and_mature recalculates lockpoints for a tx\nwith mempool parents, CalculateLockPointsAtTip ignores mempool\ninputs (height == tip+1) when computing max_input_height, leaving\nit at 0. The resulting maxInputBlock is tip->GetAncestor(0) =\ngenesis. So the freshly recalculated lockpoints still have\nmaxInputBlock=genesis after UpdateLockPoints.\n\nIf the genesis-sentinel check were inside TestLockPointValidity,\nthose legitimately-recalculated lockpoints would fail the\npost-removal assert in removeForReorg, crashing bitcoind\n(verified empirically \u2014 tested locally and the assertion fires).\n\nKeeping the check at the caller site preserves\nTestLockPointValidity's broader semantics (it answers \"is\nmaxInputBlock still on chain?\") while addressing the specific\nstale-lockpoints case in filter_final_and_mature where lp.height>0\ncombined with the genesis sentinel signals lockpoints from a\nprevious tip."
  },
  {
   "t": "2026-05-10T17:42:24Z",
   "kind": "review_comment",
   "who": "javierpmateos",
   "assoc": "NONE",
   "path": "test/functional/mempool_reorg_bip68_stale_lockpoints.py",
   "commit": "9dc0642ac58fd75993d8e5bfa5cecbbb0d5397a0",
   "in_reply_to": 3178124501,
   "text": "Done in latest push."
  },
  {
   "t": "2026-05-10T17:42:37Z",
   "kind": "review_comment",
   "who": "javierpmateos",
   "assoc": "NONE",
   "path": "test/functional/mempool_reorg_bip68_stale_lockpoints.py",
   "commit": "9dc0642ac58fd75993d8e5bfa5cecbbb0d5397a0",
   "in_reply_to": 3178654815,
   "text": "You're right, verified empirically \u2014 the bug reproduces without\nblock_Y. Removed in latest push."
  },
  {
   "t": "2026-05-13T08:57:50Z",
   "kind": "review_comment",
   "who": "Bicaru20",
   "assoc": "CONTRIBUTOR",
   "path": "src/validation.cpp",
   "commit": "c281b1ea0b17a969b29f12665c12e1b2f8aab664",
   "in_reply_to": 3178122102,
   "text": "I see, I also got the same error. Good catch!"
  },
  {
   "t": "2026-05-13T09:00:18Z",
   "kind": "comment",
   "who": "Bicaru20",
   "assoc": "CONTRIBUTOR",
   "text": "ACK c281b1ea0b"
  },
  {
   "t": "2026-05-20T21:45:41Z",
   "kind": "comment",
   "who": "Bortlesboat",
   "assoc": "CONTRIBUTOR",
   "text": "Tested c281b1ea0b17a969b29f12665c12e1b2f8aab664 on Ubuntu 24.04 WSL2, GCC 13.3, debug build with GUI and IPC disabled.\n\n`build/test/functional/test_runner.py mempool_reorg_bip68_stale_lockpoints.py --jobs=1` passes on this branch.\n\nI also ran the same test file against master 489da5df60f797a13e162e8e6ab89c0a7d9f5b2f, where it fails with:\n\n```text\nAssertionError: child_B (BIP68 enabled) must survive after fix\n```\n\nSo the regression test catches the stale-lockpoint behavior described in #35007."
  },
  {
   "t": "2026-07-15T14:43:48Z",
   "kind": "review",
   "who": "ismaelsadeeq",
   "assoc": "MEMBER",
   "state": "COMMENTED",
   "commit": "c281b1ea0b17a969b29f12665c12e1b2f8aab664",
   "text": "Good find, but I think this only handles the case where all of the transaction's inputs are unconfirmed that's the only way `maxInputBlock` ends up at genesis.\n\nIf a transaction spends a mix of confirmed and unconfirmed inputs, the cached `lp.height` still carries the tip+1 assumption from the unconfirmed input, but `maxInputBlock` points to the confirmed input's block.\n\nIf that block survives the reorg, `TestLockPointValidity` passes, the genesis check added here doesn't fire, and `CheckSequenceLocksAtTip` below fails against the lowered tip, the transaction is still falsely evicted along with its descendants.\n\nThe new test doesn't catch this because both children spend only unconfirmed parents; it fails with this adjustment.\n\n```diff\ndiff --git a/test/functional/mempool_reorg_bip68_stale_lockpoints.py b/test/functional/mempool_reorg_bip68_stale_lockpoints.py\nindex 8c73f5e5d1..a066b8be8d 100755\n--- a/test/functional/mempool_reorg_bip68_stale_lockpoints.py\n+++ b/test/functional/mempool_reorg_bip68_stale_lockpoints.py\n@@ -23,6 +23,13 @@ class MempoolReorgBip68StaleLocksTest(BitcoinTestFramework):\n         self.wallet = MiniWallet(node)\n         self.generate(self.wallet, 200)\n\n+        # Confirm a utxo one block BELOW the block we will invalidate, so that\n+        # the mixed spend below keeps a confirmed input through the reorg.\n+        mixed_funding = self.wallet.send_self_transfer(\n+            from_node=node, confirmed_only=True,\n+        )\n+        self.generate(node, 1)\n+\n         funding = self.wallet.send_self_transfer_multi(\n             from_node=node, num_outputs=2, confirmed_only=True,\n         )\n@@ -49,8 +56,22 @@ class MempoolReorgBip68StaleLocksTest(BitcoinTestFramework):\n             sequence=SEQ_BIP68_ZERO,\n         )\n\n+        child_mixed = self.wallet.create_self_transfer_multi(\n+            utxos_to_spend=[mixed_funding[\"new_utxo\"], child_B[\"new_utxo\"]],\n+            sequence=SEQ_BIP68_ZERO,\n+        )\n+        node.sendrawtransaction(child_mixed[\"hex\"])\n+\n         self.log.info(\"child_A seq=0xFFFFFFFE: %s\", child_A[\"txid\"][:16])\n         self.log.info(\"child_B seq=0x00000000: %s\", child_B[\"txid\"][:16])\n+        self.log.info(\"child_mixed confirmed+unconfirmed inputs, seq=0x00000000: %s\", child_mixed[\"txid\"][:16])\n\n         node.invalidateblock(block_setup)\n         assert_equal(node.getblockcount(), H - 1)\n@@ -59,9 +80,11 @@ class MempoolReorgBip68StaleLocksTest(BitcoinTestFramework):\n\n         assert child_A[\"txid\"] in mp, \"child_A (BIP68 disabled) must survive\"\n         assert child_B[\"txid\"] in mp, \"child_B (BIP68 enabled) must survive after fix\"\n+        assert child_mixed[\"txid\"] in mp, \"child_mixed (confirmed + unconfirmed inputs) must survive after fix\"\n\n         self.log.info(\"child_A (BIP68 disabled): SURVIVED\")\n         self.log.info(\"child_B (BIP68 enabled):  SURVIVED (stale lockpoints recalculated)\")\n+        self.log.info(\"child_mixed (mixed inputs): SURVIVED (stale lockpoints recalculated)\")\n         self.log.info(\"PASS: removeForReorg correctly recalculates BIP68 lockpoints\")\n\n```\n\nA simpler fix is to never evict on the cached values alone: recalculate whenever the lockpoints are stale, or the cached check fails, and evict only if the fresh result also fails.\nOf course, this has a tradeoff, we may recalculate when we don't need to.\n\n```diff\ndiff --git a/src/validation.cpp b/src/validation.cpp\nindex fac537f99e..adf4fc6e47 100644\n--- a/src/validation.cpp\n+++ b/src/validation.cpp\n@@ -350,11 +350,7 @@ void Chainstate::MaybeUpdateMempoolForReorg(\n         const LockPoints& lp = it->GetLockPoints();\n         // CheckSequenceLocksAtTip checks if the transaction will be final in the next block to be\n         // created on top of the new chain.\n-        if (TestLockPointValidity(m_chain, lp) && !(lp.maxInputBlock && lp.maxInputBlock->nHeight == 0 && lp.height > 0)) {\n-            if (!CheckSequenceLocksAtTip(m_chain.Tip(), lp)) {\n-                return true;\n-            }\n-        } else {\n+        if (!TestLockPointValidity(m_chain, lp) || !CheckSequenceLocksAtTip(m_chain.Tip(), lp)) {\n             const CCoinsViewMemPool view_mempool{&CoinsTip(), *m_mempool};\n             const std::optional<LockPoints> new_lock_points{CalculateLockPointsAtTip(m_chain.Tip(), view_mempool, tx)};\n             if (new_lock_points.has_value() && CheckSequenceLocksAtTip(m_chain.Tip(), *new_lock_points)) {\n```"
  },
  {
   "t": "2026-07-15T15:11:33Z",
   "kind": "comment",
   "who": "instagibbs",
   "assoc": "MEMBER",
   "text": "in addition to what @ismaelsadeeq said it seems it misses time-based variant, if MTP drops post-reorg. his fix seems to work more generally and less brittle?"
  },
  {
   "t": "2026-07-16T09:24:41Z",
   "kind": "force_push",
   "who": "javierpmateos",
   "commit": "3813fc8c9d6a70ce2e22fa2295e2ff95837ba67b"
  },
  {
   "t": "2026-07-16T09:34:16Z",
   "kind": "comment",
   "who": "javierpmateos",
   "assoc": "NONE",
   "text": "[quoted text omitted]\n\nThanks for the thorough review \u2014 you're right on both counts. I've adopted your approach of recalculating whenever the cached validity check or the sequence-lock check fails, rather than detecting the genesis sentinel specifically. It's simpler and covers the mixed confirmed/unconfirmed input case that my original check missed.\n\nI've also added your child_mixed case to the regression test (confirmed input below block_setup + unconfirmed input), which fails on the previous genesis-only approach and passes with the general recalculation.\n\nConfirmed locally: the extended test passes with this fix, and mempool_reorg.py / feature_bip68_sequence.py still pass."
  },
  {
   "t": "2026-07-16T09:34:46Z",
   "kind": "comment",
   "who": "javierpmateos",
   "assoc": "NONE",
   "text": "[quoted text omitted]\n\nGood point on the time-based variant. The recalculation approach handles it uniformly ... CheckSequenceLocksAtTip evaluates both height and time locks, so any stale cache (height or MTP-based) triggers recalculation\nthe same way.\n\nI tried adding a time-based case to the functional test but reproducing it reliably in regtest is brittle: a time-based relative lock over an unconfirmed parent is rejected as non-BIP68-final on mempool entry (the 512s lock isn't satisfied against tip+1), and forcing the MTP to drop post-reorg requires careful timestamp choreography that would make the test flaky. Given the fix covers height and time uniformly by construction, I've kept the test to the height-based and mixed-input\ncases that reproduce deterministically. Open to suggestions if you think a robust time-based case is worth adding."
  },
  {
   "t": "2026-07-20T12:39:14Z",
   "kind": "review",
   "who": "ismaelsadeeq",
   "assoc": "MEMBER",
   "state": "APPROVED",
   "commit": "c71aa2db62c98e8987dbb4b63bcc6a45da273cc5",
   "text": "ACK 3813fc8c9d6a70ce2e22fa2295e2ff95837ba67b\n\nnit: commit message is too verbose, and does not capture the time variant indicated by @instagibbs https://github.com/bitcoin/bitcoin/pull/35026#issuecomment-4982173649, this test is sufficient, but it will be nice to capture the time based variant as well to guard against future regression."
  },
  {
   "t": "2026-07-20T19:08:57Z",
   "kind": "force_push",
   "who": "javierpmateos",
   "commit": "c71aa2db62c98e8987dbb4b63bcc6a45da273cc5"
  },
  {
   "t": "2026-07-20T19:11:33Z",
   "kind": "comment",
   "who": "javierpmateos",
   "assoc": "NONE",
   "text": "[quoted text omitted]\n\nThanks! ... shortened the commit message and added the Co-authored-by trailer.\n\nOn the time-based variant: I spent some time trying to build a deterministic functional test for it and I don't think one exists cleanly. The height-based mixed case works as a regression test because lp.height caches an artificially high tip+1 from the unconfirmed input while the confirmed input stays valid at the new tip \u2014 so the tx is genuinely valid post-reorg but the stale cache marks it invalid.\n\nFor the time-based case, when the reorg lowers the MTP, the transaction tends to become genuinely invalid (the relative time hasn't actually elapsed on the reorged chain), so eviction is correct rather than a bug. I instrumented several scenarios (varying lock magnitude and reorg depth) and confirmed the eviction result is identical on this branch and on master \u2014 i.e. not a regression the test could catch.\n\nThe fix still covers the time-based path by construction, since the recalculation branch runs whenever CheckSequenceLocksAtTip fails regardless of whether the lock is height- or time-based. If you know of a timestamp arrangement that produces a genuinely-valid-but-stale time-based case, I'm happy to add it \u2014 otherwise I've kept the test to the cases that reproduce deterministically.\n\nNote: the only change since your ACK is the commit message and the Co-authored-by trailer \u2014 the code (validation.cpp and the test) is byte-identical to 3813fc8."
  },
  {
   "t": "2026-08-20T11:31:05Z",
   "kind": "comment",
   "who": "Bicaru20",
   "assoc": "CONTRIBUTOR",
   "text": "I've been playing around with the test to see how we could add a test for the time-based variant.\n\nMy logic tells me that in order to reproduce the bug, we need to modify the median time of the blocks (which is taken considering the last 11 blocks). So in theory, if we create 6 blocks with a higher timestamp using `node.setmocktime` we would modify the median (lets call this median `median_new` and the previous median `median_old`). Then when we create the transaction with the sequence number for the time-based variant, that transaction would have the minimum time set at the `median_new`.  When we invalidate the last 6 blocks, the locktime from the transaction would need to be less or equal tahn `median_old`, so if the fix works, the minimum time from the transaction will be set to the `median_old` and will not be evicted from the mempool. Here is my implementation for the test:\n\n```diff\n--- a/test/functional/mempool_reorg_bip68_stale_lockpoints.py\n+++ b/test/functional/mempool_reorg_bip68_stale_lockpoints.py\n@@ -5,11 +5,12 @@\n \"\"\"Test that removeForReorg correctly handles BIP68 transactions.\"\"\"\n\n from test_framework.test_framework import BitcoinTestFramework\n-from test_framework.util import assert_equal\n+from test_framework.util import assert_equal, assert_greater_than\n from test_framework.wallet import MiniWallet\n\n SEQ_BIP68_DISABLE = 0xFFFFFFFE\n SEQ_BIP68_ZERO = 0x00000000\n+SEQ_BIP68_ZERO_TIME = 0x00400000\n\n class MempoolReorgBip68StaleLocksTest(BitcoinTestFramework):\n@@ -31,7 +32,7 @@ class MempoolReorgBip68StaleLocksTest(BitcoinTestFramework):\n         self.generate(node, 1)\n\n         funding = self.wallet.send_self_transfer_multi(\n-            from_node=node, num_outputs=2, confirmed_only=True,\n+            from_node=node, num_outputs=3, confirmed_only=True,\n         )\n         block_setup = self.generate(node, 1)[0]\n         self.wallet.rescan_utxos(include_mempool=True)\n@@ -84,6 +85,37 @@ class MempoolReorgBip68StaleLocksTest(BitcoinTestFramework):\n         self.log.info(\"child_mixed (mixed inputs): SURVIVED (stale lockpoints recalculated)\")\n         self.log.info(\"PASS: removeForReorg correctly recalculates BIP68 lockpoints\")\n\n+        H = node.getblockcount()\n+        current_time = node.getblockheader(node.getbestblockhash())['time']\n+        # Generate 6 blocks with the same time for the median\n+        for _ in range(6):\n+            node.setmocktime(current_time + 1000)\n+            self.generate(node, 1)\n+\n+        first_median = node.getblockheader(node.getbestblockhash())['mediantime']\n+        self.log.info(f\"Median time of last block: {first_median}\")\n+\n+        node.setmocktime(current_time + 20000)\n+        block_setup = self.generate(node, 6)[0]\n+        second_median = node.getblockheader(node.getbestblockhash())['mediantime']\n+        self.log.info(f\"Median time of last block: {second_median}\")\n+\n+        # To reproduce the bug, the median must be higher than the one we previously had\n+        assert_greater_than(second_median, first_median)\n+        assert_equal(node.getblockcount(), H + 6 + 6)\n+\n+        parent_time = self.wallet.send_self_transfer(from_node=node, utxo_to_spend=funding[\"new_utxos\"][2])\n+        child_time = self.wallet.send_self_transfer(\n+            from_node=node, utxo_to_spend=parent_time[\"new_utxo\"],\n+            sequence=SEQ_BIP68_ZERO_TIME,\n+        )\n+\n+        node.invalidateblock(block_setup)\n+        breakpoint()\n+        assert_equal(node.getblockcount(), H + 6)\n+        mp = node.getrawmempool()\n+        assert child_time[\"txid\"] in mp, \"child_time (BIP68 enabled time-based) must survive after fix\"\n+        self.log.info(f\"Time of last block: {node.getblockheader(node.getbestblockhash())['mediantime']}\")\n\n```\n\nWithout the fix, the test shouldn't pass as is. But if we were to create 5 blocks instead of 6, the test should pass as the median is not modified. However, that is not the case since time passes between the creation of the blocks. That makes the test fail withouth the fix when creating 5 or less blocks instead of 6. So in order to make the test constant, I think it is important to make the median time as predictable as possible. That is way before creating the 6 blocks with a higher timestamp, I create 6 blocks with exactly the same timestamp, so the median is constant.\n\nYou can try run the test without the fix and you'll see that it will pass if we generate 5 blocks instead of 6.\n\nLet me know what you think about this approach, hope I explained myself clearly about what I am trying to do."
  },
  {
   "t": "2026-08-20T22:29:11Z",
   "kind": "force_push",
   "who": "javierpmateos",
   "commit": "8e416e0598a5e771fdbce982d18cc7e2930c4fed"
  },
  {
   "t": "2026-08-20T22:33:23Z",
   "kind": "comment",
   "who": "javierpmateos",
   "assoc": "NONE",
   "text": "Thanks ... this works, and the reasoning about stabilising the median first is the piece I was missing. I'd tried a few time-based scenarios and kept hitting cases where the eviction was genuinely correct rather than a bug; using a zero-value time lock (0x00400000) is what separates \"valid at the new tip but marked invalid by the stale cache\" from \"actually invalid\", the same way SEQ_BIP68_ZERO does on the height side.\n\nI've added it to the test (dropped the breakpoint(), kept a separate variable for the second setup block, and reset setmocktime at the end). Verified against three binaries:\n\n- v31.0rc4 (no fix): fails at child_B\n- the original genesis-sentinel approach: child_B passes, fails at child_mixed\n- this branch: all four cases pass\n\nSo the test also catches the limitation of the earlier approach, not just the absence of any fix. The median moves from 1787265629 to 1787284629 in the setup, so there's a wide margin rather than the test sitting on the edge.\n\nHappy to add a Co-authored-by trailer for the test if you'd like ... let me know what name/email to use."
  },
  {
   "t": "2026-08-22T20:37:14Z",
   "kind": "comment",
   "who": "Bicaru20",
   "assoc": "CONTRIBUTOR",
   "text": "ACK 8e416e0598\n\n[quoted text omitted]\nThat would be great, thanks! `Co-Authored-By: Bicaru20 <bicaru2@gmail.com>`"
  },
  {
   "t": "2026-08-23T15:55:35Z",
   "kind": "force_push",
   "who": "javierpmateos",
   "commit": "ea2d638bb94542290578bd65c7c16f2849c56071"
  },
  {
   "t": "2026-08-23T16:02:53Z",
   "kind": "comment",
   "who": "javierpmateos",
   "assoc": "NONE",
   "text": "Added the trailer, thanks @Bicaru20. Only the commit message changed ... the code is byte-identical to 8e416e0."
  },
  {
   "t": "2026-08-24T09:34:46Z",
   "kind": "comment",
   "who": "Bicaru20",
   "assoc": "CONTRIBUTOR",
   "text": "re-ACK ea2d638bb94542290578bd65c7c16f2849c56071"
  },
  {
   "t": "2026-09-06T11:23:42Z",
   "kind": "comment",
   "who": "sedited",
   "assoc": "MEMBER",
   "text": "@Amperstrand please don't post output from LLMs verbatim. Have you read the [AI policy](https://github.com/bitcoin/bitcoin/blob/master/doc/AI_POLICY.md) of this project?"
  }
 ],
 "labels_log": [
  {
   "t": "2026-04-07T20:13:37Z",
   "action": "labeled",
   "label": "Mempool",
   "who": "DrahtBot"
  },
  {
   "t": "2026-04-09T10:20:28Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-04-09T12:43:43Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  }
 ],
 "state_log": [],
 "text_chars": 18963,
 "text_tokens_estimate": 4740,
 "changed_paths": [
  "src/validation.cpp",
  "test/functional/mempool_reorg_bip68_stale_lockpoints.py",
  "test/functional/test_runner.py"
 ],
 "files": [
  {
   "path": "src/validation.cpp",
   "add": 1,
   "del": 5
  },
  {
   "path": "test/functional/mempool_reorg_bip68_stale_lockpoints.py",
   "add": 135,
   "del": 0
  },
  {
   "path": "test/functional/test_runner.py",
   "add": 1,
   "del": 0
  }
 ],
 "test_lines": 136,
 "git": {
  "head": "ea2d638bb94542290578bd65c7c16f2849c56071",
  "head_matches_backup": true,
  "base": "d868667fdc534c7ac04d4348c31a50e26679ef34",
  "commits": [
   {
    "sha": "ea2d638bb9",
    "subject": "mempool: recalculate stale BIP68 lockpoints in removeForReorg",
    "files": 3,
    "add": 137,
    "del": 5
   }
  ],
  "patch_truncated": false
 },
 "input_hash": "d097f0821fae5c11",
 "extracted_at": "2026-09-17T16:15:31+00:00"
}