Duplicate Delayed Holder HTLC Claim Replay After Force-Close #4572

Description

@joostjager

This document was drafted from AI-assisted analysis and test exploration.

Summary

OnchainTxHandler::update_claims_view_from_requests can accept the same logical holder HTLC timeout claim twice after a force-close on an anchor channel.

The failing shape is:

  1. A holder commitment confirms on-chain.
  2. Two offered holder HTLC outputs are turned into two single-outpoint claim requests.
  3. OnchainTxHandler merges those requests into one delayed package and parks it in locktimed_packages until the CLTV height.
  4. Before the CLTV height is reached, a later replay through normal production code rebuilds the same two single-outpoint requests from the same confirmed holder commitment.
  5. The current delayed-claim dedupe only checks for an exact outpoint-set match, so it does not treat those replayed single-outpoint requests as duplicates of the already-merged two-outpoint delayed package.
  6. The replayed requests are merged again into a second identical delayed package.
  7. At CLTV maturity, both delayed packages are restored and try to register the same ClaimId.

In debug builds this panics in lightning/src/chain/onchaintx.rs with:

assertion failed: self.pending_claim_requests.get(&claim_id).is_none()

Why This Replay Happens In Reality

This is not a fuzz-only shape.

There is a current production path where it happens:

  1. A node force-closes an anchor channel with outbound offered HTLCs.
  2. Its holder commitment confirms on-chain.
  3. ChannelMonitor::check_spend_holder_transaction calls get_broadcasted_holder_claims, which yields one single-outpoint request per holder HTLC output.
  4. OnchainTxHandler::update_claims_view_from_requests merges the two outbound timeout requests into one delayed package because their CLTV is still in the future.
  5. Later, the user claims an inbound HTLC that was already emitted as PaymentClaimable.
  6. ChannelManager::claim_funds generates a real ChannelMonitorUpdateStep::PaymentPreimage.
  7. Applying that monitor update calls ChannelMonitor::provide_payment_preimage.
  8. When the holder commitment is already confirmed, provide_payment_preimage calls get_broadcasted_holder_claims again, which rebuilds all holder HTLC claim requests for that commitment, including the same outbound timeout requests that were already delayed.

That makes the replay realistic even without any restart-only or legacy-only API.

This affects both current anchor variants:

  • keyed anchors
  • zero-fee commitments

Root Cause

The delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests is too strict.

Today it effectively asks:

  • "Do I already have a delayed package with exactly the same outpoint set as this new request?"

That is not sufficient once earlier requests have already been merged. After two single-outpoint requests have been merged into one delayed two-outpoint package, replaying either original single-outpoint request should still be treated as duplicate.

The correct question is:

  • "Is every outpoint in this new request already covered by an existing delayed package?"

Affected Production Code

  • lightning/src/chain/channelmonitor.rscheck_spend_holder_transaction
  • lightning/src/chain/channelmonitor.rsget_broadcasted_holder_claims
  • lightning/src/chain/channelmonitor.rsprovide_payment_preimage
  • lightning/src/chain/onchaintx.rsupdate_claims_view_from_requests

Impact

Debug Builds

Debug builds panic when the second restored delayed package tries to register the same ClaimId.

Release Builds

The assertion is debug-only, so release builds do not panic at that point.

From the current code, the expected release-build behavior is:

  • the same logical HTLC claim event is yielded twice with the same ClaimId
  • pending_claim_requests.insert(claim_id, req) overwrites the previous request entry for that ClaimId
  • consumers may process duplicate HTLCResolution events for the same logical claim

This looks like a correctness and liveness bug, not a direct fund-theft issue by itself.

There is some blast-radius reduction because the event pipeline and coin selection code already key work off ClaimId, so repeated handling of the same claim tends to reuse the same claim identity instead of creating an unrelated second claim. Still, duplicate claim generation is wrong and can create redundant or conflicting downstream work.

Severity

This should be treated as high priority claim-handling breakage, but not as security-critical by itself.

Reproduction

The repro test code is pushed here:

That compare contains both reproducers described below.

Focused OnchainTxHandler Unit Test

File:

  • lightning/src/chain/onchaintx.rs

Compare:

Test:

  • test_duplicate_pending_claim_request_after_force_close_replay

What it does:

  1. Builds two offered holder HTLC requests with the same future CLTV.
  2. Runs update_claims_view_from_requests once, which merges them into one delayed package.
  3. Replays the same requests again before the locktime.
  4. Advances to the locktime.

Buggy result:

  • the same delayed package is restored twice
  • the second restore hits the duplicate ClaimId assertion

Higher-Level ChannelMonitor Test Through Current Prod Code

File:

  • lightning/src/ln/monitor_tests.rs

Compare:

Test:

  • test_duplicate_delayed_holder_htlc_claims_after_claim_funds_replay

What it does:

  1. Opens an anchor channel.
  2. Adds two outbound offered HTLCs from node 0 to node 1.
  3. Adds one inbound HTLC to node 0 and leaves it at PaymentClaimable.
  4. Force-closes node 0.
  5. Confirms node 0's holder commitment, which creates the initial delayed timeout package.
  6. Calls claim_funds for the inbound HTLC, which creates a real payment-preimage monitor update and replays holder claim generation through ChannelMonitor::provide_payment_preimage.
  7. Advances to the CLTV height.

Buggy result:

  • the same duplicate ClaimId assertion is hit in OnchainTxHandler

This reproducer does not use provide_payment_preimage_unsafe_legacy.

Expected Behavior

Once a delayed package already covers an outpoint, replaying a fresh single-outpoint request for that outpoint should be ignored.

At CLTV maturity, the delayed package should be restored exactly once and should yield exactly one logical HTLC claim event for that outpoint set.

Proposed Fix Direction

Update delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests so that a new request is rejected when all of its outpoints are already covered by an existing delayed package, even if the existing package was formed by merging earlier requests.

In practice:

  • replace exact delayed-package equality with covering-package detection
  • keep the dedupe narrow so legitimately new outpoints still pass through

As defense in depth, keeping pending claim events keyed by ClaimId remains useful, but it does not address the root cause here. The primary bug is the duplicate delayed package replay.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
       blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      Skip to content

      Duplicate Delayed Holder HTLC Claim Replay After Force-Close #4572

      Description

      @joostjager

      This document was drafted from AI-assisted analysis and test exploration.

      Summary

      OnchainTxHandler::update_claims_view_from_requests can accept the same logical holder HTLC timeout claim twice after a force-close on an anchor channel.

      The failing shape is:

      1. A holder commitment confirms on-chain.
      2. Two offered holder HTLC outputs are turned into two single-outpoint claim requests.
      3. OnchainTxHandler merges those requests into one delayed package and parks it in locktimed_packages until the CLTV height.
      4. Before the CLTV height is reached, a later replay through normal production code rebuilds the same two single-outpoint requests from the same confirmed holder commitment.
      5. The current delayed-claim dedupe only checks for an exact outpoint-set match, so it does not treat those replayed single-outpoint requests as duplicates of the already-merged two-outpoint delayed package.
      6. The replayed requests are merged again into a second identical delayed package.
      7. At CLTV maturity, both delayed packages are restored and try to register the same ClaimId.

      In debug builds this panics in lightning/src/chain/onchaintx.rs with:

      assertion failed: self.pending_claim_requests.get(&claim_id).is_none()
      

      Why This Replay Happens In Reality

      This is not a fuzz-only shape.

      There is a current production path where it happens:

      1. A node force-closes an anchor channel with outbound offered HTLCs.
      2. Its holder commitment confirms on-chain.
      3. ChannelMonitor::check_spend_holder_transaction calls get_broadcasted_holder_claims, which yields one single-outpoint request per holder HTLC output.
      4. OnchainTxHandler::update_claims_view_from_requests merges the two outbound timeout requests into one delayed package because their CLTV is still in the future.
      5. Later, the user claims an inbound HTLC that was already emitted as PaymentClaimable.
      6. ChannelManager::claim_funds generates a real ChannelMonitorUpdateStep::PaymentPreimage.
      7. Applying that monitor update calls ChannelMonitor::provide_payment_preimage.
      8. When the holder commitment is already confirmed, provide_payment_preimage calls get_broadcasted_holder_claims again, which rebuilds all holder HTLC claim requests for that commitment, including the same outbound timeout requests that were already delayed.

      That makes the replay realistic even without any restart-only or legacy-only API.

      This affects both current anchor variants:

      • keyed anchors
      • zero-fee commitments

      Root Cause

      The delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests is too strict.

      Today it effectively asks:

      • "Do I already have a delayed package with exactly the same outpoint set as this new request?"

      That is not sufficient once earlier requests have already been merged. After two single-outpoint requests have been merged into one delayed two-outpoint package, replaying either original single-outpoint request should still be treated as duplicate.

      The correct question is:

      • "Is every outpoint in this new request already covered by an existing delayed package?"

      Affected Production Code

      • lightning/src/chain/channelmonitor.rscheck_spend_holder_transaction
      • lightning/src/chain/channelmonitor.rsget_broadcasted_holder_claims
      • lightning/src/chain/channelmonitor.rsprovide_payment_preimage
      • lightning/src/chain/onchaintx.rsupdate_claims_view_from_requests

      Impact

      Debug Builds

      Debug builds panic when the second restored delayed package tries to register the same ClaimId.

      Release Builds

      The assertion is debug-only, so release builds do not panic at that point.

      From the current code, the expected release-build behavior is:

      • the same logical HTLC claim event is yielded twice with the same ClaimId
      • pending_claim_requests.insert(claim_id, req) overwrites the previous request entry for that ClaimId
      • consumers may process duplicate HTLCResolution events for the same logical claim

      This looks like a correctness and liveness bug, not a direct fund-theft issue by itself.

      There is some blast-radius reduction because the event pipeline and coin selection code already key work off ClaimId, so repeated handling of the same claim tends to reuse the same claim identity instead of creating an unrelated second claim. Still, duplicate claim generation is wrong and can create redundant or conflicting downstream work.

      Severity

      This should be treated as high priority claim-handling breakage, but not as security-critical by itself.

      Reproduction

      The repro test code is pushed here:

      That compare contains both reproducers described below.

      Focused OnchainTxHandler Unit Test

      File:

      • lightning/src/chain/onchaintx.rs

      Compare:

      Test:

      • test_duplicate_pending_claim_request_after_force_close_replay

      What it does:

      1. Builds two offered holder HTLC requests with the same future CLTV.
      2. Runs update_claims_view_from_requests once, which merges them into one delayed package.
      3. Replays the same requests again before the locktime.
      4. Advances to the locktime.

      Buggy result:

      • the same delayed package is restored twice
      • the second restore hits the duplicate ClaimId assertion

      Higher-Level ChannelMonitor Test Through Current Prod Code

      File:

      • lightning/src/ln/monitor_tests.rs

      Compare:

      Test:

      • test_duplicate_delayed_holder_htlc_claims_after_claim_funds_replay

      What it does:

      1. Opens an anchor channel.
      2. Adds two outbound offered HTLCs from node 0 to node 1.
      3. Adds one inbound HTLC to node 0 and leaves it at PaymentClaimable.
      4. Force-closes node 0.
      5. Confirms node 0's holder commitment, which creates the initial delayed timeout package.
      6. Calls claim_funds for the inbound HTLC, which creates a real payment-preimage monitor update and replays holder claim generation through ChannelMonitor::provide_payment_preimage.
      7. Advances to the CLTV height.

      Buggy result:

      • the same duplicate ClaimId assertion is hit in OnchainTxHandler

      This reproducer does not use provide_payment_preimage_unsafe_legacy.

      Expected Behavior

      Once a delayed package already covers an outpoint, replaying a fresh single-outpoint request for that outpoint should be ignored.

      At CLTV maturity, the delayed package should be restored exactly once and should yield exactly one logical HTLC claim event for that outpoint set.

      Proposed Fix Direction

      Update delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests so that a new request is rejected when all of its outpoints are already covered by an existing delayed package, even if the existing package was formed by merging earlier requests.

      In practice:

      • replace exact delayed-package equality with covering-package detection
      • keep the dedupe narrow so legitimately new outpoints still pass through

      As defense in depth, keeping pending claim events keyed by ClaimId remains useful, but it does not address the root cause here. The primary bug is the duplicate delayed package replay.

      Metadata

      Metadata

      Assignees

      No one assigned

        Labels

        No labels
        No labels

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          Skip to content

          Duplicate Delayed Holder HTLC Claim Replay After Force-Close #4572

          Description

          @joostjager

          This document was drafted from AI-assisted analysis and test exploration.

          Summary

          OnchainTxHandler::update_claims_view_from_requests can accept the same logical holder HTLC timeout claim twice after a force-close on an anchor channel.

          The failing shape is:

          1. A holder commitment confirms on-chain.
          2. Two offered holder HTLC outputs are turned into two single-outpoint claim requests.
          3. OnchainTxHandler merges those requests into one delayed package and parks it in locktimed_packages until the CLTV height.
          4. Before the CLTV height is reached, a later replay through normal production code rebuilds the same two single-outpoint requests from the same confirmed holder commitment.
          5. The current delayed-claim dedupe only checks for an exact outpoint-set match, so it does not treat those replayed single-outpoint requests as duplicates of the already-merged two-outpoint delayed package.
          6. The replayed requests are merged again into a second identical delayed package.
          7. At CLTV maturity, both delayed packages are restored and try to register the same ClaimId.

          In debug builds this panics in lightning/src/chain/onchaintx.rs with:

          assertion failed: self.pending_claim_requests.get(&claim_id).is_none()
          

          Why This Replay Happens In Reality

          This is not a fuzz-only shape.

          There is a current production path where it happens:

          1. A node force-closes an anchor channel with outbound offered HTLCs.
          2. Its holder commitment confirms on-chain.
          3. ChannelMonitor::check_spend_holder_transaction calls get_broadcasted_holder_claims, which yields one single-outpoint request per holder HTLC output.
          4. OnchainTxHandler::update_claims_view_from_requests merges the two outbound timeout requests into one delayed package because their CLTV is still in the future.
          5. Later, the user claims an inbound HTLC that was already emitted as PaymentClaimable.
          6. ChannelManager::claim_funds generates a real ChannelMonitorUpdateStep::PaymentPreimage.
          7. Applying that monitor update calls ChannelMonitor::provide_payment_preimage.
          8. When the holder commitment is already confirmed, provide_payment_preimage calls get_broadcasted_holder_claims again, which rebuilds all holder HTLC claim requests for that commitment, including the same outbound timeout requests that were already delayed.

          That makes the replay realistic even without any restart-only or legacy-only API.

          This affects both current anchor variants:

          • keyed anchors
          • zero-fee commitments

          Root Cause

          The delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests is too strict.

          Today it effectively asks:

          • "Do I already have a delayed package with exactly the same outpoint set as this new request?"

          That is not sufficient once earlier requests have already been merged. After two single-outpoint requests have been merged into one delayed two-outpoint package, replaying either original single-outpoint request should still be treated as duplicate.

          The correct question is:

          • "Is every outpoint in this new request already covered by an existing delayed package?"

          Affected Production Code

          • lightning/src/chain/channelmonitor.rscheck_spend_holder_transaction
          • lightning/src/chain/channelmonitor.rsget_broadcasted_holder_claims
          • lightning/src/chain/channelmonitor.rsprovide_payment_preimage
          • lightning/src/chain/onchaintx.rsupdate_claims_view_from_requests

          Impact

          Debug Builds

          Debug builds panic when the second restored delayed package tries to register the same ClaimId.

          Release Builds

          The assertion is debug-only, so release builds do not panic at that point.

          From the current code, the expected release-build behavior is:

          • the same logical HTLC claim event is yielded twice with the same ClaimId
          • pending_claim_requests.insert(claim_id, req) overwrites the previous request entry for that ClaimId
          • consumers may process duplicate HTLCResolution events for the same logical claim

          This looks like a correctness and liveness bug, not a direct fund-theft issue by itself.

          There is some blast-radius reduction because the event pipeline and coin selection code already key work off ClaimId, so repeated handling of the same claim tends to reuse the same claim identity instead of creating an unrelated second claim. Still, duplicate claim generation is wrong and can create redundant or conflicting downstream work.

          Severity

          This should be treated as high priority claim-handling breakage, but not as security-critical by itself.

          Reproduction

          The repro test code is pushed here:

          That compare contains both reproducers described below.

          Focused OnchainTxHandler Unit Test

          File:

          • lightning/src/chain/onchaintx.rs

          Compare:

          Test:

          • test_duplicate_pending_claim_request_after_force_close_replay

          What it does:

          1. Builds two offered holder HTLC requests with the same future CLTV.
          2. Runs update_claims_view_from_requests once, which merges them into one delayed package.
          3. Replays the same requests again before the locktime.
          4. Advances to the locktime.

          Buggy result:

          • the same delayed package is restored twice
          • the second restore hits the duplicate ClaimId assertion

          Higher-Level ChannelMonitor Test Through Current Prod Code

          File:

          • lightning/src/ln/monitor_tests.rs

          Compare:

          Test:

          • test_duplicate_delayed_holder_htlc_claims_after_claim_funds_replay

          What it does:

          1. Opens an anchor channel.
          2. Adds two outbound offered HTLCs from node 0 to node 1.
          3. Adds one inbound HTLC to node 0 and leaves it at PaymentClaimable.
          4. Force-closes node 0.
          5. Confirms node 0's holder commitment, which creates the initial delayed timeout package.
          6. Calls claim_funds for the inbound HTLC, which creates a real payment-preimage monitor update and replays holder claim generation through ChannelMonitor::provide_payment_preimage.
          7. Advances to the CLTV height.

          Buggy result:

          • the same duplicate ClaimId assertion is hit in OnchainTxHandler

          This reproducer does not use provide_payment_preimage_unsafe_legacy.

          Expected Behavior

          Once a delayed package already covers an outpoint, replaying a fresh single-outpoint request for that outpoint should be ignored.

          At CLTV maturity, the delayed package should be restored exactly once and should yield exactly one logical HTLC claim event for that outpoint set.

          Proposed Fix Direction

          Update delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests so that a new request is rejected when all of its outpoints are already covered by an existing delayed package, even if the existing package was formed by merging earlier requests.

          In practice:

          • replace exact delayed-package equality with covering-package detection
          • keep the dedupe narrow so legitimately new outpoints still pass through

          As defense in depth, keeping pending claim events keyed by ClaimId remains useful, but it does not address the root cause here. The primary bug is the duplicate delayed package replay.

          Metadata

          Metadata

          Assignees

          No one assigned

            Labels

            No labels
            No labels

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              Duplicate Delayed Holder HTLC Claim Replay After Force-Close #4572

              Description

              @joostjager

              This document was drafted from AI-assisted analysis and test exploration.

              Summary

              OnchainTxHandler::update_claims_view_from_requests can accept the same logical holder HTLC timeout claim twice after a force-close on an anchor channel.

              The failing shape is:

              1. A holder commitment confirms on-chain.
              2. Two offered holder HTLC outputs are turned into two single-outpoint claim requests.
              3. OnchainTxHandler merges those requests into one delayed package and parks it in locktimed_packages until the CLTV height.
              4. Before the CLTV height is reached, a later replay through normal production code rebuilds the same two single-outpoint requests from the same confirmed holder commitment.
              5. The current delayed-claim dedupe only checks for an exact outpoint-set match, so it does not treat those replayed single-outpoint requests as duplicates of the already-merged two-outpoint delayed package.
              6. The replayed requests are merged again into a second identical delayed package.
              7. At CLTV maturity, both delayed packages are restored and try to register the same ClaimId.

              In debug builds this panics in lightning/src/chain/onchaintx.rs with:

              assertion failed: self.pending_claim_requests.get(&claim_id).is_none()
              

              Why This Replay Happens In Reality

              This is not a fuzz-only shape.

              There is a current production path where it happens:

              1. A node force-closes an anchor channel with outbound offered HTLCs.
              2. Its holder commitment confirms on-chain.
              3. ChannelMonitor::check_spend_holder_transaction calls get_broadcasted_holder_claims, which yields one single-outpoint request per holder HTLC output.
              4. OnchainTxHandler::update_claims_view_from_requests merges the two outbound timeout requests into one delayed package because their CLTV is still in the future.
              5. Later, the user claims an inbound HTLC that was already emitted as PaymentClaimable.
              6. ChannelManager::claim_funds generates a real ChannelMonitorUpdateStep::PaymentPreimage.
              7. Applying that monitor update calls ChannelMonitor::provide_payment_preimage.
              8. When the holder commitment is already confirmed, provide_payment_preimage calls get_broadcasted_holder_claims again, which rebuilds all holder HTLC claim requests for that commitment, including the same outbound timeout requests that were already delayed.

              That makes the replay realistic even without any restart-only or legacy-only API.

              This affects both current anchor variants:

              • keyed anchors
              • zero-fee commitments

              Root Cause

              The delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests is too strict.

              Today it effectively asks:

              • "Do I already have a delayed package with exactly the same outpoint set as this new request?"

              That is not sufficient once earlier requests have already been merged. After two single-outpoint requests have been merged into one delayed two-outpoint package, replaying either original single-outpoint request should still be treated as duplicate.

              The correct question is:

              • "Is every outpoint in this new request already covered by an existing delayed package?"

              Affected Production Code

              • lightning/src/chain/channelmonitor.rscheck_spend_holder_transaction
              • lightning/src/chain/channelmonitor.rsget_broadcasted_holder_claims
              • lightning/src/chain/channelmonitor.rsprovide_payment_preimage
              • lightning/src/chain/onchaintx.rsupdate_claims_view_from_requests

              Impact

              Debug Builds

              Debug builds panic when the second restored delayed package tries to register the same ClaimId.

              Release Builds

              The assertion is debug-only, so release builds do not panic at that point.

              From the current code, the expected release-build behavior is:

              • the same logical HTLC claim event is yielded twice with the same ClaimId
              • pending_claim_requests.insert(claim_id, req) overwrites the previous request entry for that ClaimId
              • consumers may process duplicate HTLCResolution events for the same logical claim

              This looks like a correctness and liveness bug, not a direct fund-theft issue by itself.

              There is some blast-radius reduction because the event pipeline and coin selection code already key work off ClaimId, so repeated handling of the same claim tends to reuse the same claim identity instead of creating an unrelated second claim. Still, duplicate claim generation is wrong and can create redundant or conflicting downstream work.

              Severity

              This should be treated as high priority claim-handling breakage, but not as security-critical by itself.

              Reproduction

              The repro test code is pushed here:

              That compare contains both reproducers described below.

              Focused OnchainTxHandler Unit Test

              File:

              • lightning/src/chain/onchaintx.rs

              Compare:

              Test:

              • test_duplicate_pending_claim_request_after_force_close_replay

              What it does:

              1. Builds two offered holder HTLC requests with the same future CLTV.
              2. Runs update_claims_view_from_requests once, which merges them into one delayed package.
              3. Replays the same requests again before the locktime.
              4. Advances to the locktime.

              Buggy result:

              • the same delayed package is restored twice
              • the second restore hits the duplicate ClaimId assertion

              Higher-Level ChannelMonitor Test Through Current Prod Code

              File:

              • lightning/src/ln/monitor_tests.rs

              Compare:

              Test:

              • test_duplicate_delayed_holder_htlc_claims_after_claim_funds_replay

              What it does:

              1. Opens an anchor channel.
              2. Adds two outbound offered HTLCs from node 0 to node 1.
              3. Adds one inbound HTLC to node 0 and leaves it at PaymentClaimable.
              4. Force-closes node 0.
              5. Confirms node 0's holder commitment, which creates the initial delayed timeout package.
              6. Calls claim_funds for the inbound HTLC, which creates a real payment-preimage monitor update and replays holder claim generation through ChannelMonitor::provide_payment_preimage.
              7. Advances to the CLTV height.

              Buggy result:

              • the same duplicate ClaimId assertion is hit in OnchainTxHandler

              This reproducer does not use provide_payment_preimage_unsafe_legacy.

              Expected Behavior

              Once a delayed package already covers an outpoint, replaying a fresh single-outpoint request for that outpoint should be ignored.

              At CLTV maturity, the delayed package should be restored exactly once and should yield exactly one logical HTLC claim event for that outpoint set.

              Proposed Fix Direction

              Update delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests so that a new request is rejected when all of its outpoints are already covered by an existing delayed package, even if the existing package was formed by merging earlier requests.

              In practice:

              • replace exact delayed-package equality with covering-package detection
              • keep the dedupe narrow so legitimately new outpoints still pass through

              As defense in depth, keeping pending claim events keyed by ClaimId remains useful, but it does not address the root cause here. The primary bug is the duplicate delayed package replay.

              Metadata

              Metadata

              Assignees

              No one assigned

                Labels

                No labels
                No labels

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  Skip to content

                  Duplicate Delayed Holder HTLC Claim Replay After Force-Close #4572

                  Description

                  @joostjager

                  This document was drafted from AI-assisted analysis and test exploration.

                  Summary

                  OnchainTxHandler::update_claims_view_from_requests can accept the same logical holder HTLC timeout claim twice after a force-close on an anchor channel.

                  The failing shape is:

                  1. A holder commitment confirms on-chain.
                  2. Two offered holder HTLC outputs are turned into two single-outpoint claim requests.
                  3. OnchainTxHandler merges those requests into one delayed package and parks it in locktimed_packages until the CLTV height.
                  4. Before the CLTV height is reached, a later replay through normal production code rebuilds the same two single-outpoint requests from the same confirmed holder commitment.
                  5. The current delayed-claim dedupe only checks for an exact outpoint-set match, so it does not treat those replayed single-outpoint requests as duplicates of the already-merged two-outpoint delayed package.
                  6. The replayed requests are merged again into a second identical delayed package.
                  7. At CLTV maturity, both delayed packages are restored and try to register the same ClaimId.

                  In debug builds this panics in lightning/src/chain/onchaintx.rs with:

                  assertion failed: self.pending_claim_requests.get(&claim_id).is_none()
                  

                  Why This Replay Happens In Reality

                  This is not a fuzz-only shape.

                  There is a current production path where it happens:

                  1. A node force-closes an anchor channel with outbound offered HTLCs.
                  2. Its holder commitment confirms on-chain.
                  3. ChannelMonitor::check_spend_holder_transaction calls get_broadcasted_holder_claims, which yields one single-outpoint request per holder HTLC output.
                  4. OnchainTxHandler::update_claims_view_from_requests merges the two outbound timeout requests into one delayed package because their CLTV is still in the future.
                  5. Later, the user claims an inbound HTLC that was already emitted as PaymentClaimable.
                  6. ChannelManager::claim_funds generates a real ChannelMonitorUpdateStep::PaymentPreimage.
                  7. Applying that monitor update calls ChannelMonitor::provide_payment_preimage.
                  8. When the holder commitment is already confirmed, provide_payment_preimage calls get_broadcasted_holder_claims again, which rebuilds all holder HTLC claim requests for that commitment, including the same outbound timeout requests that were already delayed.

                  That makes the replay realistic even without any restart-only or legacy-only API.

                  This affects both current anchor variants:

                  • keyed anchors
                  • zero-fee commitments

                  Root Cause

                  The delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests is too strict.

                  Today it effectively asks:

                  • "Do I already have a delayed package with exactly the same outpoint set as this new request?"

                  That is not sufficient once earlier requests have already been merged. After two single-outpoint requests have been merged into one delayed two-outpoint package, replaying either original single-outpoint request should still be treated as duplicate.

                  The correct question is:

                  • "Is every outpoint in this new request already covered by an existing delayed package?"

                  Affected Production Code

                  • lightning/src/chain/channelmonitor.rscheck_spend_holder_transaction
                  • lightning/src/chain/channelmonitor.rsget_broadcasted_holder_claims
                  • lightning/src/chain/channelmonitor.rsprovide_payment_preimage
                  • lightning/src/chain/onchaintx.rsupdate_claims_view_from_requests

                  Impact

                  Debug Builds

                  Debug builds panic when the second restored delayed package tries to register the same ClaimId.

                  Release Builds

                  The assertion is debug-only, so release builds do not panic at that point.

                  From the current code, the expected release-build behavior is:

                  • the same logical HTLC claim event is yielded twice with the same ClaimId
                  • pending_claim_requests.insert(claim_id, req) overwrites the previous request entry for that ClaimId
                  • consumers may process duplicate HTLCResolution events for the same logical claim

                  This looks like a correctness and liveness bug, not a direct fund-theft issue by itself.

                  There is some blast-radius reduction because the event pipeline and coin selection code already key work off ClaimId, so repeated handling of the same claim tends to reuse the same claim identity instead of creating an unrelated second claim. Still, duplicate claim generation is wrong and can create redundant or conflicting downstream work.

                  Severity

                  This should be treated as high priority claim-handling breakage, but not as security-critical by itself.

                  Reproduction

                  The repro test code is pushed here:

                  That compare contains both reproducers described below.

                  Focused OnchainTxHandler Unit Test

                  File:

                  • lightning/src/chain/onchaintx.rs

                  Compare:

                  Test:

                  • test_duplicate_pending_claim_request_after_force_close_replay

                  What it does:

                  1. Builds two offered holder HTLC requests with the same future CLTV.
                  2. Runs update_claims_view_from_requests once, which merges them into one delayed package.
                  3. Replays the same requests again before the locktime.
                  4. Advances to the locktime.

                  Buggy result:

                  • the same delayed package is restored twice
                  • the second restore hits the duplicate ClaimId assertion

                  Higher-Level ChannelMonitor Test Through Current Prod Code

                  File:

                  • lightning/src/ln/monitor_tests.rs

                  Compare:

                  Test:

                  • test_duplicate_delayed_holder_htlc_claims_after_claim_funds_replay

                  What it does:

                  1. Opens an anchor channel.
                  2. Adds two outbound offered HTLCs from node 0 to node 1.
                  3. Adds one inbound HTLC to node 0 and leaves it at PaymentClaimable.
                  4. Force-closes node 0.
                  5. Confirms node 0's holder commitment, which creates the initial delayed timeout package.
                  6. Calls claim_funds for the inbound HTLC, which creates a real payment-preimage monitor update and replays holder claim generation through ChannelMonitor::provide_payment_preimage.
                  7. Advances to the CLTV height.

                  Buggy result:

                  • the same duplicate ClaimId assertion is hit in OnchainTxHandler

                  This reproducer does not use provide_payment_preimage_unsafe_legacy.

                  Expected Behavior

                  Once a delayed package already covers an outpoint, replaying a fresh single-outpoint request for that outpoint should be ignored.

                  At CLTV maturity, the delayed package should be restored exactly once and should yield exactly one logical HTLC claim event for that outpoint set.

                  Proposed Fix Direction

                  Update delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests so that a new request is rejected when all of its outpoints are already covered by an existing delayed package, even if the existing package was formed by merging earlier requests.

                  In practice:

                  • replace exact delayed-package equality with covering-package detection
                  • keep the dedupe narrow so legitimately new outpoints still pass through

                  As defense in depth, keeping pending claim events keyed by ClaimId remains useful, but it does not address the root cause here. The primary bug is the duplicate delayed package replay.

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Labels

                    No labels
                    No labels

                    Type

                    No type

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      Skip to content

                      Duplicate Delayed Holder HTLC Claim Replay After Force-Close #4572

                      Description

                      @joostjager

                      This document was drafted from AI-assisted analysis and test exploration.

                      Summary

                      OnchainTxHandler::update_claims_view_from_requests can accept the same logical holder HTLC timeout claim twice after a force-close on an anchor channel.

                      The failing shape is:

                      1. A holder commitment confirms on-chain.
                      2. Two offered holder HTLC outputs are turned into two single-outpoint claim requests.
                      3. OnchainTxHandler merges those requests into one delayed package and parks it in locktimed_packages until the CLTV height.
                      4. Before the CLTV height is reached, a later replay through normal production code rebuilds the same two single-outpoint requests from the same confirmed holder commitment.
                      5. The current delayed-claim dedupe only checks for an exact outpoint-set match, so it does not treat those replayed single-outpoint requests as duplicates of the already-merged two-outpoint delayed package.
                      6. The replayed requests are merged again into a second identical delayed package.
                      7. At CLTV maturity, both delayed packages are restored and try to register the same ClaimId.

                      In debug builds this panics in lightning/src/chain/onchaintx.rs with:

                      assertion failed: self.pending_claim_requests.get(&claim_id).is_none()
                      

                      Why This Replay Happens In Reality

                      This is not a fuzz-only shape.

                      There is a current production path where it happens:

                      1. A node force-closes an anchor channel with outbound offered HTLCs.
                      2. Its holder commitment confirms on-chain.
                      3. ChannelMonitor::check_spend_holder_transaction calls get_broadcasted_holder_claims, which yields one single-outpoint request per holder HTLC output.
                      4. OnchainTxHandler::update_claims_view_from_requests merges the two outbound timeout requests into one delayed package because their CLTV is still in the future.
                      5. Later, the user claims an inbound HTLC that was already emitted as PaymentClaimable.
                      6. ChannelManager::claim_funds generates a real ChannelMonitorUpdateStep::PaymentPreimage.
                      7. Applying that monitor update calls ChannelMonitor::provide_payment_preimage.
                      8. When the holder commitment is already confirmed, provide_payment_preimage calls get_broadcasted_holder_claims again, which rebuilds all holder HTLC claim requests for that commitment, including the same outbound timeout requests that were already delayed.

                      That makes the replay realistic even without any restart-only or legacy-only API.

                      This affects both current anchor variants:

                      • keyed anchors
                      • zero-fee commitments

                      Root Cause

                      The delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests is too strict.

                      Today it effectively asks:

                      • "Do I already have a delayed package with exactly the same outpoint set as this new request?"

                      That is not sufficient once earlier requests have already been merged. After two single-outpoint requests have been merged into one delayed two-outpoint package, replaying either original single-outpoint request should still be treated as duplicate.

                      The correct question is:

                      • "Is every outpoint in this new request already covered by an existing delayed package?"

                      Affected Production Code

                      • lightning/src/chain/channelmonitor.rscheck_spend_holder_transaction
                      • lightning/src/chain/channelmonitor.rsget_broadcasted_holder_claims
                      • lightning/src/chain/channelmonitor.rsprovide_payment_preimage
                      • lightning/src/chain/onchaintx.rsupdate_claims_view_from_requests

                      Impact

                      Debug Builds

                      Debug builds panic when the second restored delayed package tries to register the same ClaimId.

                      Release Builds

                      The assertion is debug-only, so release builds do not panic at that point.

                      From the current code, the expected release-build behavior is:

                      • the same logical HTLC claim event is yielded twice with the same ClaimId
                      • pending_claim_requests.insert(claim_id, req) overwrites the previous request entry for that ClaimId
                      • consumers may process duplicate HTLCResolution events for the same logical claim

                      This looks like a correctness and liveness bug, not a direct fund-theft issue by itself.

                      There is some blast-radius reduction because the event pipeline and coin selection code already key work off ClaimId, so repeated handling of the same claim tends to reuse the same claim identity instead of creating an unrelated second claim. Still, duplicate claim generation is wrong and can create redundant or conflicting downstream work.

                      Severity

                      This should be treated as high priority claim-handling breakage, but not as security-critical by itself.

                      Reproduction

                      The repro test code is pushed here:

                      That compare contains both reproducers described below.

                      Focused OnchainTxHandler Unit Test

                      File:

                      • lightning/src/chain/onchaintx.rs

                      Compare:

                      Test:

                      • test_duplicate_pending_claim_request_after_force_close_replay

                      What it does:

                      1. Builds two offered holder HTLC requests with the same future CLTV.
                      2. Runs update_claims_view_from_requests once, which merges them into one delayed package.
                      3. Replays the same requests again before the locktime.
                      4. Advances to the locktime.

                      Buggy result:

                      • the same delayed package is restored twice
                      • the second restore hits the duplicate ClaimId assertion

                      Higher-Level ChannelMonitor Test Through Current Prod Code

                      File:

                      • lightning/src/ln/monitor_tests.rs

                      Compare:

                      Test:

                      • test_duplicate_delayed_holder_htlc_claims_after_claim_funds_replay

                      What it does:

                      1. Opens an anchor channel.
                      2. Adds two outbound offered HTLCs from node 0 to node 1.
                      3. Adds one inbound HTLC to node 0 and leaves it at PaymentClaimable.
                      4. Force-closes node 0.
                      5. Confirms node 0's holder commitment, which creates the initial delayed timeout package.
                      6. Calls claim_funds for the inbound HTLC, which creates a real payment-preimage monitor update and replays holder claim generation through ChannelMonitor::provide_payment_preimage.
                      7. Advances to the CLTV height.

                      Buggy result:

                      • the same duplicate ClaimId assertion is hit in OnchainTxHandler

                      This reproducer does not use provide_payment_preimage_unsafe_legacy.

                      Expected Behavior

                      Once a delayed package already covers an outpoint, replaying a fresh single-outpoint request for that outpoint should be ignored.

                      At CLTV maturity, the delayed package should be restored exactly once and should yield exactly one logical HTLC claim event for that outpoint set.

                      Proposed Fix Direction

                      Update delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests so that a new request is rejected when all of its outpoints are already covered by an existing delayed package, even if the existing package was formed by merging earlier requests.

                      In practice:

                      • replace exact delayed-package equality with covering-package detection
                      • keep the dedupe narrow so legitimately new outpoints still pass through

                      As defense in depth, keeping pending claim events keyed by ClaimId remains useful, but it does not address the root cause here. The primary bug is the duplicate delayed package replay.

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Labels

                        No labels
                        No labels

                        Type

                        No type

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          Skip to content

                          Duplicate Delayed Holder HTLC Claim Replay After Force-Close #4572

                          Description

                          @joostjager

                          This document was drafted from AI-assisted analysis and test exploration.

                          Summary

                          OnchainTxHandler::update_claims_view_from_requests can accept the same logical holder HTLC timeout claim twice after a force-close on an anchor channel.

                          The failing shape is:

                          1. A holder commitment confirms on-chain.
                          2. Two offered holder HTLC outputs are turned into two single-outpoint claim requests.
                          3. OnchainTxHandler merges those requests into one delayed package and parks it in locktimed_packages until the CLTV height.
                          4. Before the CLTV height is reached, a later replay through normal production code rebuilds the same two single-outpoint requests from the same confirmed holder commitment.
                          5. The current delayed-claim dedupe only checks for an exact outpoint-set match, so it does not treat those replayed single-outpoint requests as duplicates of the already-merged two-outpoint delayed package.
                          6. The replayed requests are merged again into a second identical delayed package.
                          7. At CLTV maturity, both delayed packages are restored and try to register the same ClaimId.

                          In debug builds this panics in lightning/src/chain/onchaintx.rs with:

                          assertion failed: self.pending_claim_requests.get(&claim_id).is_none()
                          

                          Why This Replay Happens In Reality

                          This is not a fuzz-only shape.

                          There is a current production path where it happens:

                          1. A node force-closes an anchor channel with outbound offered HTLCs.
                          2. Its holder commitment confirms on-chain.
                          3. ChannelMonitor::check_spend_holder_transaction calls get_broadcasted_holder_claims, which yields one single-outpoint request per holder HTLC output.
                          4. OnchainTxHandler::update_claims_view_from_requests merges the two outbound timeout requests into one delayed package because their CLTV is still in the future.
                          5. Later, the user claims an inbound HTLC that was already emitted as PaymentClaimable.
                          6. ChannelManager::claim_funds generates a real ChannelMonitorUpdateStep::PaymentPreimage.
                          7. Applying that monitor update calls ChannelMonitor::provide_payment_preimage.
                          8. When the holder commitment is already confirmed, provide_payment_preimage calls get_broadcasted_holder_claims again, which rebuilds all holder HTLC claim requests for that commitment, including the same outbound timeout requests that were already delayed.

                          That makes the replay realistic even without any restart-only or legacy-only API.

                          This affects both current anchor variants:

                          • keyed anchors
                          • zero-fee commitments

                          Root Cause

                          The delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests is too strict.

                          Today it effectively asks:

                          • "Do I already have a delayed package with exactly the same outpoint set as this new request?"

                          That is not sufficient once earlier requests have already been merged. After two single-outpoint requests have been merged into one delayed two-outpoint package, replaying either original single-outpoint request should still be treated as duplicate.

                          The correct question is:

                          • "Is every outpoint in this new request already covered by an existing delayed package?"

                          Affected Production Code

                          • lightning/src/chain/channelmonitor.rscheck_spend_holder_transaction
                          • lightning/src/chain/channelmonitor.rsget_broadcasted_holder_claims
                          • lightning/src/chain/channelmonitor.rsprovide_payment_preimage
                          • lightning/src/chain/onchaintx.rsupdate_claims_view_from_requests

                          Impact

                          Debug Builds

                          Debug builds panic when the second restored delayed package tries to register the same ClaimId.

                          Release Builds

                          The assertion is debug-only, so release builds do not panic at that point.

                          From the current code, the expected release-build behavior is:

                          • the same logical HTLC claim event is yielded twice with the same ClaimId
                          • pending_claim_requests.insert(claim_id, req) overwrites the previous request entry for that ClaimId
                          • consumers may process duplicate HTLCResolution events for the same logical claim

                          This looks like a correctness and liveness bug, not a direct fund-theft issue by itself.

                          There is some blast-radius reduction because the event pipeline and coin selection code already key work off ClaimId, so repeated handling of the same claim tends to reuse the same claim identity instead of creating an unrelated second claim. Still, duplicate claim generation is wrong and can create redundant or conflicting downstream work.

                          Severity

                          This should be treated as high priority claim-handling breakage, but not as security-critical by itself.

                          Reproduction

                          The repro test code is pushed here:

                          That compare contains both reproducers described below.

                          Focused OnchainTxHandler Unit Test

                          File:

                          • lightning/src/chain/onchaintx.rs

                          Compare:

                          Test:

                          • test_duplicate_pending_claim_request_after_force_close_replay

                          What it does:

                          1. Builds two offered holder HTLC requests with the same future CLTV.
                          2. Runs update_claims_view_from_requests once, which merges them into one delayed package.
                          3. Replays the same requests again before the locktime.
                          4. Advances to the locktime.

                          Buggy result:

                          • the same delayed package is restored twice
                          • the second restore hits the duplicate ClaimId assertion

                          Higher-Level ChannelMonitor Test Through Current Prod Code

                          File:

                          • lightning/src/ln/monitor_tests.rs

                          Compare:

                          Test:

                          • test_duplicate_delayed_holder_htlc_claims_after_claim_funds_replay

                          What it does:

                          1. Opens an anchor channel.
                          2. Adds two outbound offered HTLCs from node 0 to node 1.
                          3. Adds one inbound HTLC to node 0 and leaves it at PaymentClaimable.
                          4. Force-closes node 0.
                          5. Confirms node 0's holder commitment, which creates the initial delayed timeout package.
                          6. Calls claim_funds for the inbound HTLC, which creates a real payment-preimage monitor update and replays holder claim generation through ChannelMonitor::provide_payment_preimage.
                          7. Advances to the CLTV height.

                          Buggy result:

                          • the same duplicate ClaimId assertion is hit in OnchainTxHandler

                          This reproducer does not use provide_payment_preimage_unsafe_legacy.

                          Expected Behavior

                          Once a delayed package already covers an outpoint, replaying a fresh single-outpoint request for that outpoint should be ignored.

                          At CLTV maturity, the delayed package should be restored exactly once and should yield exactly one logical HTLC claim event for that outpoint set.

                          Proposed Fix Direction

                          Update delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests so that a new request is rejected when all of its outpoints are already covered by an existing delayed package, even if the existing package was formed by merging earlier requests.

                          In practice:

                          • replace exact delayed-package equality with covering-package detection
                          • keep the dedupe narrow so legitimately new outpoints still pass through

                          As defense in depth, keeping pending claim events keyed by ClaimId remains useful, but it does not address the root cause here. The primary bug is the duplicate delayed package replay.

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Labels

                            No labels
                            No labels

                            Type

                            No type

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              Skip to content

                              Duplicate Delayed Holder HTLC Claim Replay After Force-Close #4572

                              Description

                              @joostjager

                              This document was drafted from AI-assisted analysis and test exploration.

                              Summary

                              OnchainTxHandler::update_claims_view_from_requests can accept the same logical holder HTLC timeout claim twice after a force-close on an anchor channel.

                              The failing shape is:

                              1. A holder commitment confirms on-chain.
                              2. Two offered holder HTLC outputs are turned into two single-outpoint claim requests.
                              3. OnchainTxHandler merges those requests into one delayed package and parks it in locktimed_packages until the CLTV height.
                              4. Before the CLTV height is reached, a later replay through normal production code rebuilds the same two single-outpoint requests from the same confirmed holder commitment.
                              5. The current delayed-claim dedupe only checks for an exact outpoint-set match, so it does not treat those replayed single-outpoint requests as duplicates of the already-merged two-outpoint delayed package.
                              6. The replayed requests are merged again into a second identical delayed package.
                              7. At CLTV maturity, both delayed packages are restored and try to register the same ClaimId.

                              In debug builds this panics in lightning/src/chain/onchaintx.rs with:

                              assertion failed: self.pending_claim_requests.get(&claim_id).is_none()
                              

                              Why This Replay Happens In Reality

                              This is not a fuzz-only shape.

                              There is a current production path where it happens:

                              1. A node force-closes an anchor channel with outbound offered HTLCs.
                              2. Its holder commitment confirms on-chain.
                              3. ChannelMonitor::check_spend_holder_transaction calls get_broadcasted_holder_claims, which yields one single-outpoint request per holder HTLC output.
                              4. OnchainTxHandler::update_claims_view_from_requests merges the two outbound timeout requests into one delayed package because their CLTV is still in the future.
                              5. Later, the user claims an inbound HTLC that was already emitted as PaymentClaimable.
                              6. ChannelManager::claim_funds generates a real ChannelMonitorUpdateStep::PaymentPreimage.
                              7. Applying that monitor update calls ChannelMonitor::provide_payment_preimage.
                              8. When the holder commitment is already confirmed, provide_payment_preimage calls get_broadcasted_holder_claims again, which rebuilds all holder HTLC claim requests for that commitment, including the same outbound timeout requests that were already delayed.

                              That makes the replay realistic even without any restart-only or legacy-only API.

                              This affects both current anchor variants:

                              • keyed anchors
                              • zero-fee commitments

                              Root Cause

                              The delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests is too strict.

                              Today it effectively asks:

                              • "Do I already have a delayed package with exactly the same outpoint set as this new request?"

                              That is not sufficient once earlier requests have already been merged. After two single-outpoint requests have been merged into one delayed two-outpoint package, replaying either original single-outpoint request should still be treated as duplicate.

                              The correct question is:

                              • "Is every outpoint in this new request already covered by an existing delayed package?"

                              Affected Production Code

                              • lightning/src/chain/channelmonitor.rscheck_spend_holder_transaction
                              • lightning/src/chain/channelmonitor.rsget_broadcasted_holder_claims
                              • lightning/src/chain/channelmonitor.rsprovide_payment_preimage
                              • lightning/src/chain/onchaintx.rsupdate_claims_view_from_requests

                              Impact

                              Debug Builds

                              Debug builds panic when the second restored delayed package tries to register the same ClaimId.

                              Release Builds

                              The assertion is debug-only, so release builds do not panic at that point.

                              From the current code, the expected release-build behavior is:

                              • the same logical HTLC claim event is yielded twice with the same ClaimId
                              • pending_claim_requests.insert(claim_id, req) overwrites the previous request entry for that ClaimId
                              • consumers may process duplicate HTLCResolution events for the same logical claim

                              This looks like a correctness and liveness bug, not a direct fund-theft issue by itself.

                              There is some blast-radius reduction because the event pipeline and coin selection code already key work off ClaimId, so repeated handling of the same claim tends to reuse the same claim identity instead of creating an unrelated second claim. Still, duplicate claim generation is wrong and can create redundant or conflicting downstream work.

                              Severity

                              This should be treated as high priority claim-handling breakage, but not as security-critical by itself.

                              Reproduction

                              The repro test code is pushed here:

                              That compare contains both reproducers described below.

                              Focused OnchainTxHandler Unit Test

                              File:

                              • lightning/src/chain/onchaintx.rs

                              Compare:

                              Test:

                              • test_duplicate_pending_claim_request_after_force_close_replay

                              What it does:

                              1. Builds two offered holder HTLC requests with the same future CLTV.
                              2. Runs update_claims_view_from_requests once, which merges them into one delayed package.
                              3. Replays the same requests again before the locktime.
                              4. Advances to the locktime.

                              Buggy result:

                              • the same delayed package is restored twice
                              • the second restore hits the duplicate ClaimId assertion

                              Higher-Level ChannelMonitor Test Through Current Prod Code

                              File:

                              • lightning/src/ln/monitor_tests.rs

                              Compare:

                              Test:

                              • test_duplicate_delayed_holder_htlc_claims_after_claim_funds_replay

                              What it does:

                              1. Opens an anchor channel.
                              2. Adds two outbound offered HTLCs from node 0 to node 1.
                              3. Adds one inbound HTLC to node 0 and leaves it at PaymentClaimable.
                              4. Force-closes node 0.
                              5. Confirms node 0's holder commitment, which creates the initial delayed timeout package.
                              6. Calls claim_funds for the inbound HTLC, which creates a real payment-preimage monitor update and replays holder claim generation through ChannelMonitor::provide_payment_preimage.
                              7. Advances to the CLTV height.

                              Buggy result:

                              • the same duplicate ClaimId assertion is hit in OnchainTxHandler

                              This reproducer does not use provide_payment_preimage_unsafe_legacy.

                              Expected Behavior

                              Once a delayed package already covers an outpoint, replaying a fresh single-outpoint request for that outpoint should be ignored.

                              At CLTV maturity, the delayed package should be restored exactly once and should yield exactly one logical HTLC claim event for that outpoint set.

                              Proposed Fix Direction

                              Update delayed-claim dedupe in OnchainTxHandler::update_claims_view_from_requests so that a new request is rejected when all of its outpoints are already covered by an existing delayed package, even if the existing package was formed by merging earlier requests.

                              In practice:

                              • replace exact delayed-package equality with covering-package detection
                              • keep the dedupe narrow so legitimately new outpoints still pass through

                              As defense in depth, keeping pending claim events keyed by ClaimId remains useful, but it does not address the root cause here. The primary bug is the duplicate delayed package replay.

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Labels

                                No labels
                                No labels

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions