Persisted duplicate-claim completion action can trip reload assertion #4738

Description

@joostjager

Note: This draft was AI-generated and may contain mistakes. Reproduces on main (7b63ffa)

This was found while triaging a rare crash from chanmon consistency fuzzing. The
fuzz case was reduced to a focused functional test that does not use splice
commands.

Problem

MonitorUpdateCompletionAction::FreeDuplicateClaimImmediately is documented as
an in-memory action that should not be written to disk. In one duplicate HTLC
claim path, however, it can be pushed into monitor_update_blocked_actions while
a previous-hop monitor update is still in flight. If the ChannelManager is
serialized in that state and then reloaded, deserialization finds the queued
FreeDuplicateClaimImmediately action and hits the debug assertion:

Non-event-generating channel freeing should not appear in our queue

Affected behavior / minimal sequence

The minimized functional reproducer uses three nodes, A, B, and C:

  1. Route a payment A -> B -> C.
  2. C claims the payment and sends B an update_fulfill_htlc.
  3. B learns the preimage and tries to claim the previous-hop HTLC from A.
  4. B's A-B monitor update is left InProgress, so B does not yet emit the
    fulfill back to A.
  5. C goes on-chain with its commitment and HTLC-success transaction.
  6. B's B-C monitor observes the same preimage on-chain and replays the claim.
  7. The replay is a duplicate claim against A-B, but A-B still has an in-flight
    monitor update, so the duplicate-free action is queued.
  8. Serializing and reloading B hits the assertion during ChannelManager read.

Expected behavior

Reload should not assert on valid duplicate monitor replay. Either the
duplicate-free action should be handled immediately, or it should be represented
in persisted state in a way that the reload path accepts and processes
correctly.

Actual behavior

The duplicate-free action can be serialized in monitor_update_blocked_actions.
On reload, the read path treats that variant as invalid queued state and triggers
the debug assertion above.

Why this looks like a project bug

This does not appear to depend on fuzz-harness-only behavior. The focused
reproducer uses normal functional-test helpers for channels, payments, async
monitor persistence, block connection, and node reload. It also does not require
splicing.

The on-chain duplicate preimage path is expected in general: a monitor can later
report an HTLC resolution that the ChannelManager already learned offchain.
The bug appears to be the combination of that duplicate claim with an in-flight
previous-hop monitor update, which causes an action documented as non-persistent
to enter serialized manager state.

Impact

The verified impact is a debug-assertion crash on reload. The assertion is a
debug_assert!, so the exact panic is not expected in normal release builds.

A remote downstream peer can influence the payment, message, and on-chain parts
of the sequence, but the exact state also requires local timing: async monitor
persistence must leave the previous-hop update in flight, and the node must
serialize and reload in that window. I have not verified funds loss. I also have
not yet verified whether release builds always recover cleanly, or whether they
can leave a channel temporarily or permanently blocked after the invalid action
is persisted.

Focused reproducer test

#[test]#[should_panic(expected = "Non-event-generating channel freeing should not appear in our queue")]fntest_duplicate_onchain_claim_reloaded_with_inflight_prev_hop_update(){let chanmon_cfgs = create_chanmon_cfgs(3);let node_cfgs = create_node_cfgs(3,&chanmon_cfgs);let persister;let chain_mon;let node_b_reload;let legacy_cfg = test_legacy_channel_config();let node_chanmgrs = create_node_chanmgrs(3,&node_cfgs,&[Some(legacy_cfg.clone()),Some(legacy_cfg.clone()),Some(legacy_cfg)],);letmut nodes = create_network(3,&node_cfgs,&node_chanmgrs);let node_b_id = nodes[1].node.get_our_node_id();let node_c_id = nodes[2].node.get_our_node_id();let chan_id_ab = create_announced_chan_between_nodes(&nodes,0,1).2;let chan_bc = create_announced_chan_between_nodes(&nodes,1,2);// This payment gives B both an inbound edge, A-B, and an outbound edge, B-C. The crash// requires B to first learn the preimage from C, then later learn the same preimage again// from the B-C monitor after C has gone on-chain.let(payment_preimage, payment_hash, ..) =
route_payment(&nodes[0],&[&nodes[1],&nodes[2]],1_000_000);
nodes[2].node.claim_funds(payment_preimage);check_added_monitors(&nodes[2],1);expect_payment_claimed!(nodes[2], payment_hash,1_000_000);// Leave B's A-B preimage monitor update in flight. This is the critical persistence state:// B has enough information to claim from A, but the monitor update that makes the preimage// durable for the inbound edge has not completed.
chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress);let cs_updates = get_htlc_update_msgs(&nodes[2],&node_b_id);
nodes[1].node.handle_update_fulfill_htlc(node_c_id, cs_updates.update_fulfill_htlcs[0].clone());check_added_monitors(&nodes[1],1);// If B emitted the fulfill here, the A-B monitor update would not be blocked and the later// duplicate claim would free inline instead of being parked for monitor completion.assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());// C's local commitment still contains the HTLC because B has only received the fulfill, not// completed C's commitment_signed exchange. Mining C's commitment plus HTLC-success makes B's// monitor observe the same preimage through the on-chain path.let cs_txn = get_local_commitment_txn!(nodes[2], chan_bc.2);assert!(cs_txn.len() >= 2,"Expected commitment + HTLC-success tx, got {}", cs_txn.len());mine_transaction(&nodes[1],&cs_txn[0]);check_closed_broadcast(&nodes[1],1,true);check_added_monitors(&nodes[1],1);let events = nodes[1].node.get_and_clear_pending_events();assert!(events.iter().any(|e| matches!(e,Event::ChannelClosed{ .. })));mine_transaction(&nodes[1],&cs_txn[1]);connect_blocks(&nodes[1],ANTI_REORG_DELAY);// Draining events here forces B to process the monitor HTLC event before serialization. The// replayed preimage is a duplicate against A-B, but A-B still has an in-flight monitor update,// so the duplicate-free action is queued instead of run immediately.assert!(nodes[1].node.get_and_clear_pending_events().is_empty());// Reload with the manager serialized after the duplicate action was queued. During read, the// queued action is found in monitor_update_blocked_actions even though that action class is// expected to be purely in-memory and non-persistent.let mon_ab = get_monitor!(nodes[1], chan_id_ab).encode();let mon_bc = get_monitor!(nodes[1], chan_bc.2).encode();let manager_b = nodes[1].node.encode();reload_node!(nodes[1],&manager_b,&[&mon_ab,&mon_bc], persister, chain_mon, node_b_reload);}

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

      Persisted duplicate-claim completion action can trip reload assertion #4738

      Description

      @joostjager

      Note: This draft was AI-generated and may contain mistakes. Reproduces on main (7b63ffa)

      This was found while triaging a rare crash from chanmon consistency fuzzing. The
      fuzz case was reduced to a focused functional test that does not use splice
      commands.

      Problem

      MonitorUpdateCompletionAction::FreeDuplicateClaimImmediately is documented as
      an in-memory action that should not be written to disk. In one duplicate HTLC
      claim path, however, it can be pushed into monitor_update_blocked_actions while
      a previous-hop monitor update is still in flight. If the ChannelManager is
      serialized in that state and then reloaded, deserialization finds the queued
      FreeDuplicateClaimImmediately action and hits the debug assertion:

      Non-event-generating channel freeing should not appear in our queue
      

      Affected behavior / minimal sequence

      The minimized functional reproducer uses three nodes, A, B, and C:

      1. Route a payment A -> B -> C.
      2. C claims the payment and sends B an update_fulfill_htlc.
      3. B learns the preimage and tries to claim the previous-hop HTLC from A.
      4. B's A-B monitor update is left InProgress, so B does not yet emit the
        fulfill back to A.
      5. C goes on-chain with its commitment and HTLC-success transaction.
      6. B's B-C monitor observes the same preimage on-chain and replays the claim.
      7. The replay is a duplicate claim against A-B, but A-B still has an in-flight
        monitor update, so the duplicate-free action is queued.
      8. Serializing and reloading B hits the assertion during ChannelManager read.

      Expected behavior

      Reload should not assert on valid duplicate monitor replay. Either the
      duplicate-free action should be handled immediately, or it should be represented
      in persisted state in a way that the reload path accepts and processes
      correctly.

      Actual behavior

      The duplicate-free action can be serialized in monitor_update_blocked_actions.
      On reload, the read path treats that variant as invalid queued state and triggers
      the debug assertion above.

      Why this looks like a project bug

      This does not appear to depend on fuzz-harness-only behavior. The focused
      reproducer uses normal functional-test helpers for channels, payments, async
      monitor persistence, block connection, and node reload. It also does not require
      splicing.

      The on-chain duplicate preimage path is expected in general: a monitor can later
      report an HTLC resolution that the ChannelManager already learned offchain.
      The bug appears to be the combination of that duplicate claim with an in-flight
      previous-hop monitor update, which causes an action documented as non-persistent
      to enter serialized manager state.

      Impact

      The verified impact is a debug-assertion crash on reload. The assertion is a
      debug_assert!, so the exact panic is not expected in normal release builds.

      A remote downstream peer can influence the payment, message, and on-chain parts
      of the sequence, but the exact state also requires local timing: async monitor
      persistence must leave the previous-hop update in flight, and the node must
      serialize and reload in that window. I have not verified funds loss. I also have
      not yet verified whether release builds always recover cleanly, or whether they
      can leave a channel temporarily or permanently blocked after the invalid action
      is persisted.

      Focused reproducer test

      #[test]#[should_panic(expected = "Non-event-generating channel freeing should not appear in our queue")]fntest_duplicate_onchain_claim_reloaded_with_inflight_prev_hop_update(){let chanmon_cfgs = create_chanmon_cfgs(3);let node_cfgs = create_node_cfgs(3,&chanmon_cfgs);let persister;let chain_mon;let node_b_reload;let legacy_cfg = test_legacy_channel_config();let node_chanmgrs = create_node_chanmgrs(3,&node_cfgs,&[Some(legacy_cfg.clone()),Some(legacy_cfg.clone()),Some(legacy_cfg)],);letmut nodes = create_network(3,&node_cfgs,&node_chanmgrs);let node_b_id = nodes[1].node.get_our_node_id();let node_c_id = nodes[2].node.get_our_node_id();let chan_id_ab = create_announced_chan_between_nodes(&nodes,0,1).2;let chan_bc = create_announced_chan_between_nodes(&nodes,1,2);// This payment gives B both an inbound edge, A-B, and an outbound edge, B-C. The crash// requires B to first learn the preimage from C, then later learn the same preimage again// from the B-C monitor after C has gone on-chain.let(payment_preimage, payment_hash, ..) =
      route_payment(&nodes[0],&[&nodes[1],&nodes[2]],1_000_000);
      nodes[2].node.claim_funds(payment_preimage);check_added_monitors(&nodes[2],1);expect_payment_claimed!(nodes[2], payment_hash,1_000_000);// Leave B's A-B preimage monitor update in flight. This is the critical persistence state:// B has enough information to claim from A, but the monitor update that makes the preimage// durable for the inbound edge has not completed.
      chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress);let cs_updates = get_htlc_update_msgs(&nodes[2],&node_b_id);
      nodes[1].node.handle_update_fulfill_htlc(node_c_id, cs_updates.update_fulfill_htlcs[0].clone());check_added_monitors(&nodes[1],1);// If B emitted the fulfill here, the A-B monitor update would not be blocked and the later// duplicate claim would free inline instead of being parked for monitor completion.assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());// C's local commitment still contains the HTLC because B has only received the fulfill, not// completed C's commitment_signed exchange. Mining C's commitment plus HTLC-success makes B's// monitor observe the same preimage through the on-chain path.let cs_txn = get_local_commitment_txn!(nodes[2], chan_bc.2);assert!(cs_txn.len() >= 2,"Expected commitment + HTLC-success tx, got {}", cs_txn.len());mine_transaction(&nodes[1],&cs_txn[0]);check_closed_broadcast(&nodes[1],1,true);check_added_monitors(&nodes[1],1);let events = nodes[1].node.get_and_clear_pending_events();assert!(events.iter().any(|e| matches!(e,Event::ChannelClosed{ .. })));mine_transaction(&nodes[1],&cs_txn[1]);connect_blocks(&nodes[1],ANTI_REORG_DELAY);// Draining events here forces B to process the monitor HTLC event before serialization. The// replayed preimage is a duplicate against A-B, but A-B still has an in-flight monitor update,// so the duplicate-free action is queued instead of run immediately.assert!(nodes[1].node.get_and_clear_pending_events().is_empty());// Reload with the manager serialized after the duplicate action was queued. During read, the// queued action is found in monitor_update_blocked_actions even though that action class is// expected to be purely in-memory and non-persistent.let mon_ab = get_monitor!(nodes[1], chan_id_ab).encode();let mon_bc = get_monitor!(nodes[1], chan_bc.2).encode();let manager_b = nodes[1].node.encode();reload_node!(nodes[1],&manager_b,&[&mon_ab,&mon_bc], persister, chain_mon, node_b_reload);}

      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

          Persisted duplicate-claim completion action can trip reload assertion #4738

          Description

          @joostjager

          Note: This draft was AI-generated and may contain mistakes. Reproduces on main (7b63ffa)

          This was found while triaging a rare crash from chanmon consistency fuzzing. The
          fuzz case was reduced to a focused functional test that does not use splice
          commands.

          Problem

          MonitorUpdateCompletionAction::FreeDuplicateClaimImmediately is documented as
          an in-memory action that should not be written to disk. In one duplicate HTLC
          claim path, however, it can be pushed into monitor_update_blocked_actions while
          a previous-hop monitor update is still in flight. If the ChannelManager is
          serialized in that state and then reloaded, deserialization finds the queued
          FreeDuplicateClaimImmediately action and hits the debug assertion:

          Non-event-generating channel freeing should not appear in our queue
          

          Affected behavior / minimal sequence

          The minimized functional reproducer uses three nodes, A, B, and C:

          1. Route a payment A -> B -> C.
          2. C claims the payment and sends B an update_fulfill_htlc.
          3. B learns the preimage and tries to claim the previous-hop HTLC from A.
          4. B's A-B monitor update is left InProgress, so B does not yet emit the
            fulfill back to A.
          5. C goes on-chain with its commitment and HTLC-success transaction.
          6. B's B-C monitor observes the same preimage on-chain and replays the claim.
          7. The replay is a duplicate claim against A-B, but A-B still has an in-flight
            monitor update, so the duplicate-free action is queued.
          8. Serializing and reloading B hits the assertion during ChannelManager read.

          Expected behavior

          Reload should not assert on valid duplicate monitor replay. Either the
          duplicate-free action should be handled immediately, or it should be represented
          in persisted state in a way that the reload path accepts and processes
          correctly.

          Actual behavior

          The duplicate-free action can be serialized in monitor_update_blocked_actions.
          On reload, the read path treats that variant as invalid queued state and triggers
          the debug assertion above.

          Why this looks like a project bug

          This does not appear to depend on fuzz-harness-only behavior. The focused
          reproducer uses normal functional-test helpers for channels, payments, async
          monitor persistence, block connection, and node reload. It also does not require
          splicing.

          The on-chain duplicate preimage path is expected in general: a monitor can later
          report an HTLC resolution that the ChannelManager already learned offchain.
          The bug appears to be the combination of that duplicate claim with an in-flight
          previous-hop monitor update, which causes an action documented as non-persistent
          to enter serialized manager state.

          Impact

          The verified impact is a debug-assertion crash on reload. The assertion is a
          debug_assert!, so the exact panic is not expected in normal release builds.

          A remote downstream peer can influence the payment, message, and on-chain parts
          of the sequence, but the exact state also requires local timing: async monitor
          persistence must leave the previous-hop update in flight, and the node must
          serialize and reload in that window. I have not verified funds loss. I also have
          not yet verified whether release builds always recover cleanly, or whether they
          can leave a channel temporarily or permanently blocked after the invalid action
          is persisted.

          Focused reproducer test

          #[test]#[should_panic(expected = "Non-event-generating channel freeing should not appear in our queue")]fntest_duplicate_onchain_claim_reloaded_with_inflight_prev_hop_update(){let chanmon_cfgs = create_chanmon_cfgs(3);let node_cfgs = create_node_cfgs(3,&chanmon_cfgs);let persister;let chain_mon;let node_b_reload;let legacy_cfg = test_legacy_channel_config();let node_chanmgrs = create_node_chanmgrs(3,&node_cfgs,&[Some(legacy_cfg.clone()),Some(legacy_cfg.clone()),Some(legacy_cfg)],);letmut nodes = create_network(3,&node_cfgs,&node_chanmgrs);let node_b_id = nodes[1].node.get_our_node_id();let node_c_id = nodes[2].node.get_our_node_id();let chan_id_ab = create_announced_chan_between_nodes(&nodes,0,1).2;let chan_bc = create_announced_chan_between_nodes(&nodes,1,2);// This payment gives B both an inbound edge, A-B, and an outbound edge, B-C. The crash// requires B to first learn the preimage from C, then later learn the same preimage again// from the B-C monitor after C has gone on-chain.let(payment_preimage, payment_hash, ..) =
          route_payment(&nodes[0],&[&nodes[1],&nodes[2]],1_000_000);
          nodes[2].node.claim_funds(payment_preimage);check_added_monitors(&nodes[2],1);expect_payment_claimed!(nodes[2], payment_hash,1_000_000);// Leave B's A-B preimage monitor update in flight. This is the critical persistence state:// B has enough information to claim from A, but the monitor update that makes the preimage// durable for the inbound edge has not completed.
          chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress);let cs_updates = get_htlc_update_msgs(&nodes[2],&node_b_id);
          nodes[1].node.handle_update_fulfill_htlc(node_c_id, cs_updates.update_fulfill_htlcs[0].clone());check_added_monitors(&nodes[1],1);// If B emitted the fulfill here, the A-B monitor update would not be blocked and the later// duplicate claim would free inline instead of being parked for monitor completion.assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());// C's local commitment still contains the HTLC because B has only received the fulfill, not// completed C's commitment_signed exchange. Mining C's commitment plus HTLC-success makes B's// monitor observe the same preimage through the on-chain path.let cs_txn = get_local_commitment_txn!(nodes[2], chan_bc.2);assert!(cs_txn.len() >= 2,"Expected commitment + HTLC-success tx, got {}", cs_txn.len());mine_transaction(&nodes[1],&cs_txn[0]);check_closed_broadcast(&nodes[1],1,true);check_added_monitors(&nodes[1],1);let events = nodes[1].node.get_and_clear_pending_events();assert!(events.iter().any(|e| matches!(e,Event::ChannelClosed{ .. })));mine_transaction(&nodes[1],&cs_txn[1]);connect_blocks(&nodes[1],ANTI_REORG_DELAY);// Draining events here forces B to process the monitor HTLC event before serialization. The// replayed preimage is a duplicate against A-B, but A-B still has an in-flight monitor update,// so the duplicate-free action is queued instead of run immediately.assert!(nodes[1].node.get_and_clear_pending_events().is_empty());// Reload with the manager serialized after the duplicate action was queued. During read, the// queued action is found in monitor_update_blocked_actions even though that action class is// expected to be purely in-memory and non-persistent.let mon_ab = get_monitor!(nodes[1], chan_id_ab).encode();let mon_bc = get_monitor!(nodes[1], chan_bc.2).encode();let manager_b = nodes[1].node.encode();reload_node!(nodes[1],&manager_b,&[&mon_ab,&mon_bc], persister, chain_mon, node_b_reload);}

          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

              Persisted duplicate-claim completion action can trip reload assertion #4738

              Description

              @joostjager

              Note: This draft was AI-generated and may contain mistakes. Reproduces on main (7b63ffa)

              This was found while triaging a rare crash from chanmon consistency fuzzing. The
              fuzz case was reduced to a focused functional test that does not use splice
              commands.

              Problem

              MonitorUpdateCompletionAction::FreeDuplicateClaimImmediately is documented as
              an in-memory action that should not be written to disk. In one duplicate HTLC
              claim path, however, it can be pushed into monitor_update_blocked_actions while
              a previous-hop monitor update is still in flight. If the ChannelManager is
              serialized in that state and then reloaded, deserialization finds the queued
              FreeDuplicateClaimImmediately action and hits the debug assertion:

              Non-event-generating channel freeing should not appear in our queue
              

              Affected behavior / minimal sequence

              The minimized functional reproducer uses three nodes, A, B, and C:

              1. Route a payment A -> B -> C.
              2. C claims the payment and sends B an update_fulfill_htlc.
              3. B learns the preimage and tries to claim the previous-hop HTLC from A.
              4. B's A-B monitor update is left InProgress, so B does not yet emit the
                fulfill back to A.
              5. C goes on-chain with its commitment and HTLC-success transaction.
              6. B's B-C monitor observes the same preimage on-chain and replays the claim.
              7. The replay is a duplicate claim against A-B, but A-B still has an in-flight
                monitor update, so the duplicate-free action is queued.
              8. Serializing and reloading B hits the assertion during ChannelManager read.

              Expected behavior

              Reload should not assert on valid duplicate monitor replay. Either the
              duplicate-free action should be handled immediately, or it should be represented
              in persisted state in a way that the reload path accepts and processes
              correctly.

              Actual behavior

              The duplicate-free action can be serialized in monitor_update_blocked_actions.
              On reload, the read path treats that variant as invalid queued state and triggers
              the debug assertion above.

              Why this looks like a project bug

              This does not appear to depend on fuzz-harness-only behavior. The focused
              reproducer uses normal functional-test helpers for channels, payments, async
              monitor persistence, block connection, and node reload. It also does not require
              splicing.

              The on-chain duplicate preimage path is expected in general: a monitor can later
              report an HTLC resolution that the ChannelManager already learned offchain.
              The bug appears to be the combination of that duplicate claim with an in-flight
              previous-hop monitor update, which causes an action documented as non-persistent
              to enter serialized manager state.

              Impact

              The verified impact is a debug-assertion crash on reload. The assertion is a
              debug_assert!, so the exact panic is not expected in normal release builds.

              A remote downstream peer can influence the payment, message, and on-chain parts
              of the sequence, but the exact state also requires local timing: async monitor
              persistence must leave the previous-hop update in flight, and the node must
              serialize and reload in that window. I have not verified funds loss. I also have
              not yet verified whether release builds always recover cleanly, or whether they
              can leave a channel temporarily or permanently blocked after the invalid action
              is persisted.

              Focused reproducer test

              #[test]#[should_panic(expected = "Non-event-generating channel freeing should not appear in our queue")]fntest_duplicate_onchain_claim_reloaded_with_inflight_prev_hop_update(){let chanmon_cfgs = create_chanmon_cfgs(3);let node_cfgs = create_node_cfgs(3,&chanmon_cfgs);let persister;let chain_mon;let node_b_reload;let legacy_cfg = test_legacy_channel_config();let node_chanmgrs = create_node_chanmgrs(3,&node_cfgs,&[Some(legacy_cfg.clone()),Some(legacy_cfg.clone()),Some(legacy_cfg)],);letmut nodes = create_network(3,&node_cfgs,&node_chanmgrs);let node_b_id = nodes[1].node.get_our_node_id();let node_c_id = nodes[2].node.get_our_node_id();let chan_id_ab = create_announced_chan_between_nodes(&nodes,0,1).2;let chan_bc = create_announced_chan_between_nodes(&nodes,1,2);// This payment gives B both an inbound edge, A-B, and an outbound edge, B-C. The crash// requires B to first learn the preimage from C, then later learn the same preimage again// from the B-C monitor after C has gone on-chain.let(payment_preimage, payment_hash, ..) =
              route_payment(&nodes[0],&[&nodes[1],&nodes[2]],1_000_000);
              nodes[2].node.claim_funds(payment_preimage);check_added_monitors(&nodes[2],1);expect_payment_claimed!(nodes[2], payment_hash,1_000_000);// Leave B's A-B preimage monitor update in flight. This is the critical persistence state:// B has enough information to claim from A, but the monitor update that makes the preimage// durable for the inbound edge has not completed.
              chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress);let cs_updates = get_htlc_update_msgs(&nodes[2],&node_b_id);
              nodes[1].node.handle_update_fulfill_htlc(node_c_id, cs_updates.update_fulfill_htlcs[0].clone());check_added_monitors(&nodes[1],1);// If B emitted the fulfill here, the A-B monitor update would not be blocked and the later// duplicate claim would free inline instead of being parked for monitor completion.assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());// C's local commitment still contains the HTLC because B has only received the fulfill, not// completed C's commitment_signed exchange. Mining C's commitment plus HTLC-success makes B's// monitor observe the same preimage through the on-chain path.let cs_txn = get_local_commitment_txn!(nodes[2], chan_bc.2);assert!(cs_txn.len() >= 2,"Expected commitment + HTLC-success tx, got {}", cs_txn.len());mine_transaction(&nodes[1],&cs_txn[0]);check_closed_broadcast(&nodes[1],1,true);check_added_monitors(&nodes[1],1);let events = nodes[1].node.get_and_clear_pending_events();assert!(events.iter().any(|e| matches!(e,Event::ChannelClosed{ .. })));mine_transaction(&nodes[1],&cs_txn[1]);connect_blocks(&nodes[1],ANTI_REORG_DELAY);// Draining events here forces B to process the monitor HTLC event before serialization. The// replayed preimage is a duplicate against A-B, but A-B still has an in-flight monitor update,// so the duplicate-free action is queued instead of run immediately.assert!(nodes[1].node.get_and_clear_pending_events().is_empty());// Reload with the manager serialized after the duplicate action was queued. During read, the// queued action is found in monitor_update_blocked_actions even though that action class is// expected to be purely in-memory and non-persistent.let mon_ab = get_monitor!(nodes[1], chan_id_ab).encode();let mon_bc = get_monitor!(nodes[1], chan_bc.2).encode();let manager_b = nodes[1].node.encode();reload_node!(nodes[1],&manager_b,&[&mon_ab,&mon_bc], persister, chain_mon, node_b_reload);}

              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

                  Persisted duplicate-claim completion action can trip reload assertion #4738

                  Description

                  @joostjager

                  Note: This draft was AI-generated and may contain mistakes. Reproduces on main (7b63ffa)

                  This was found while triaging a rare crash from chanmon consistency fuzzing. The
                  fuzz case was reduced to a focused functional test that does not use splice
                  commands.

                  Problem

                  MonitorUpdateCompletionAction::FreeDuplicateClaimImmediately is documented as
                  an in-memory action that should not be written to disk. In one duplicate HTLC
                  claim path, however, it can be pushed into monitor_update_blocked_actions while
                  a previous-hop monitor update is still in flight. If the ChannelManager is
                  serialized in that state and then reloaded, deserialization finds the queued
                  FreeDuplicateClaimImmediately action and hits the debug assertion:

                  Non-event-generating channel freeing should not appear in our queue
                  

                  Affected behavior / minimal sequence

                  The minimized functional reproducer uses three nodes, A, B, and C:

                  1. Route a payment A -> B -> C.
                  2. C claims the payment and sends B an update_fulfill_htlc.
                  3. B learns the preimage and tries to claim the previous-hop HTLC from A.
                  4. B's A-B monitor update is left InProgress, so B does not yet emit the
                    fulfill back to A.
                  5. C goes on-chain with its commitment and HTLC-success transaction.
                  6. B's B-C monitor observes the same preimage on-chain and replays the claim.
                  7. The replay is a duplicate claim against A-B, but A-B still has an in-flight
                    monitor update, so the duplicate-free action is queued.
                  8. Serializing and reloading B hits the assertion during ChannelManager read.

                  Expected behavior

                  Reload should not assert on valid duplicate monitor replay. Either the
                  duplicate-free action should be handled immediately, or it should be represented
                  in persisted state in a way that the reload path accepts and processes
                  correctly.

                  Actual behavior

                  The duplicate-free action can be serialized in monitor_update_blocked_actions.
                  On reload, the read path treats that variant as invalid queued state and triggers
                  the debug assertion above.

                  Why this looks like a project bug

                  This does not appear to depend on fuzz-harness-only behavior. The focused
                  reproducer uses normal functional-test helpers for channels, payments, async
                  monitor persistence, block connection, and node reload. It also does not require
                  splicing.

                  The on-chain duplicate preimage path is expected in general: a monitor can later
                  report an HTLC resolution that the ChannelManager already learned offchain.
                  The bug appears to be the combination of that duplicate claim with an in-flight
                  previous-hop monitor update, which causes an action documented as non-persistent
                  to enter serialized manager state.

                  Impact

                  The verified impact is a debug-assertion crash on reload. The assertion is a
                  debug_assert!, so the exact panic is not expected in normal release builds.

                  A remote downstream peer can influence the payment, message, and on-chain parts
                  of the sequence, but the exact state also requires local timing: async monitor
                  persistence must leave the previous-hop update in flight, and the node must
                  serialize and reload in that window. I have not verified funds loss. I also have
                  not yet verified whether release builds always recover cleanly, or whether they
                  can leave a channel temporarily or permanently blocked after the invalid action
                  is persisted.

                  Focused reproducer test

                  #[test]#[should_panic(expected = "Non-event-generating channel freeing should not appear in our queue")]fntest_duplicate_onchain_claim_reloaded_with_inflight_prev_hop_update(){let chanmon_cfgs = create_chanmon_cfgs(3);let node_cfgs = create_node_cfgs(3,&chanmon_cfgs);let persister;let chain_mon;let node_b_reload;let legacy_cfg = test_legacy_channel_config();let node_chanmgrs = create_node_chanmgrs(3,&node_cfgs,&[Some(legacy_cfg.clone()),Some(legacy_cfg.clone()),Some(legacy_cfg)],);letmut nodes = create_network(3,&node_cfgs,&node_chanmgrs);let node_b_id = nodes[1].node.get_our_node_id();let node_c_id = nodes[2].node.get_our_node_id();let chan_id_ab = create_announced_chan_between_nodes(&nodes,0,1).2;let chan_bc = create_announced_chan_between_nodes(&nodes,1,2);// This payment gives B both an inbound edge, A-B, and an outbound edge, B-C. The crash// requires B to first learn the preimage from C, then later learn the same preimage again// from the B-C monitor after C has gone on-chain.let(payment_preimage, payment_hash, ..) =
                  route_payment(&nodes[0],&[&nodes[1],&nodes[2]],1_000_000);
                  nodes[2].node.claim_funds(payment_preimage);check_added_monitors(&nodes[2],1);expect_payment_claimed!(nodes[2], payment_hash,1_000_000);// Leave B's A-B preimage monitor update in flight. This is the critical persistence state:// B has enough information to claim from A, but the monitor update that makes the preimage// durable for the inbound edge has not completed.
                  chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress);let cs_updates = get_htlc_update_msgs(&nodes[2],&node_b_id);
                  nodes[1].node.handle_update_fulfill_htlc(node_c_id, cs_updates.update_fulfill_htlcs[0].clone());check_added_monitors(&nodes[1],1);// If B emitted the fulfill here, the A-B monitor update would not be blocked and the later// duplicate claim would free inline instead of being parked for monitor completion.assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());// C's local commitment still contains the HTLC because B has only received the fulfill, not// completed C's commitment_signed exchange. Mining C's commitment plus HTLC-success makes B's// monitor observe the same preimage through the on-chain path.let cs_txn = get_local_commitment_txn!(nodes[2], chan_bc.2);assert!(cs_txn.len() >= 2,"Expected commitment + HTLC-success tx, got {}", cs_txn.len());mine_transaction(&nodes[1],&cs_txn[0]);check_closed_broadcast(&nodes[1],1,true);check_added_monitors(&nodes[1],1);let events = nodes[1].node.get_and_clear_pending_events();assert!(events.iter().any(|e| matches!(e,Event::ChannelClosed{ .. })));mine_transaction(&nodes[1],&cs_txn[1]);connect_blocks(&nodes[1],ANTI_REORG_DELAY);// Draining events here forces B to process the monitor HTLC event before serialization. The// replayed preimage is a duplicate against A-B, but A-B still has an in-flight monitor update,// so the duplicate-free action is queued instead of run immediately.assert!(nodes[1].node.get_and_clear_pending_events().is_empty());// Reload with the manager serialized after the duplicate action was queued. During read, the// queued action is found in monitor_update_blocked_actions even though that action class is// expected to be purely in-memory and non-persistent.let mon_ab = get_monitor!(nodes[1], chan_id_ab).encode();let mon_bc = get_monitor!(nodes[1], chan_bc.2).encode();let manager_b = nodes[1].node.encode();reload_node!(nodes[1],&manager_b,&[&mon_ab,&mon_bc], persister, chain_mon, node_b_reload);}

                  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

                      Persisted duplicate-claim completion action can trip reload assertion #4738

                      Description

                      @joostjager

                      Note: This draft was AI-generated and may contain mistakes. Reproduces on main (7b63ffa)

                      This was found while triaging a rare crash from chanmon consistency fuzzing. The
                      fuzz case was reduced to a focused functional test that does not use splice
                      commands.

                      Problem

                      MonitorUpdateCompletionAction::FreeDuplicateClaimImmediately is documented as
                      an in-memory action that should not be written to disk. In one duplicate HTLC
                      claim path, however, it can be pushed into monitor_update_blocked_actions while
                      a previous-hop monitor update is still in flight. If the ChannelManager is
                      serialized in that state and then reloaded, deserialization finds the queued
                      FreeDuplicateClaimImmediately action and hits the debug assertion:

                      Non-event-generating channel freeing should not appear in our queue
                      

                      Affected behavior / minimal sequence

                      The minimized functional reproducer uses three nodes, A, B, and C:

                      1. Route a payment A -> B -> C.
                      2. C claims the payment and sends B an update_fulfill_htlc.
                      3. B learns the preimage and tries to claim the previous-hop HTLC from A.
                      4. B's A-B monitor update is left InProgress, so B does not yet emit the
                        fulfill back to A.
                      5. C goes on-chain with its commitment and HTLC-success transaction.
                      6. B's B-C monitor observes the same preimage on-chain and replays the claim.
                      7. The replay is a duplicate claim against A-B, but A-B still has an in-flight
                        monitor update, so the duplicate-free action is queued.
                      8. Serializing and reloading B hits the assertion during ChannelManager read.

                      Expected behavior

                      Reload should not assert on valid duplicate monitor replay. Either the
                      duplicate-free action should be handled immediately, or it should be represented
                      in persisted state in a way that the reload path accepts and processes
                      correctly.

                      Actual behavior

                      The duplicate-free action can be serialized in monitor_update_blocked_actions.
                      On reload, the read path treats that variant as invalid queued state and triggers
                      the debug assertion above.

                      Why this looks like a project bug

                      This does not appear to depend on fuzz-harness-only behavior. The focused
                      reproducer uses normal functional-test helpers for channels, payments, async
                      monitor persistence, block connection, and node reload. It also does not require
                      splicing.

                      The on-chain duplicate preimage path is expected in general: a monitor can later
                      report an HTLC resolution that the ChannelManager already learned offchain.
                      The bug appears to be the combination of that duplicate claim with an in-flight
                      previous-hop monitor update, which causes an action documented as non-persistent
                      to enter serialized manager state.

                      Impact

                      The verified impact is a debug-assertion crash on reload. The assertion is a
                      debug_assert!, so the exact panic is not expected in normal release builds.

                      A remote downstream peer can influence the payment, message, and on-chain parts
                      of the sequence, but the exact state also requires local timing: async monitor
                      persistence must leave the previous-hop update in flight, and the node must
                      serialize and reload in that window. I have not verified funds loss. I also have
                      not yet verified whether release builds always recover cleanly, or whether they
                      can leave a channel temporarily or permanently blocked after the invalid action
                      is persisted.

                      Focused reproducer test

                      #[test]#[should_panic(expected = "Non-event-generating channel freeing should not appear in our queue")]fntest_duplicate_onchain_claim_reloaded_with_inflight_prev_hop_update(){let chanmon_cfgs = create_chanmon_cfgs(3);let node_cfgs = create_node_cfgs(3,&chanmon_cfgs);let persister;let chain_mon;let node_b_reload;let legacy_cfg = test_legacy_channel_config();let node_chanmgrs = create_node_chanmgrs(3,&node_cfgs,&[Some(legacy_cfg.clone()),Some(legacy_cfg.clone()),Some(legacy_cfg)],);letmut nodes = create_network(3,&node_cfgs,&node_chanmgrs);let node_b_id = nodes[1].node.get_our_node_id();let node_c_id = nodes[2].node.get_our_node_id();let chan_id_ab = create_announced_chan_between_nodes(&nodes,0,1).2;let chan_bc = create_announced_chan_between_nodes(&nodes,1,2);// This payment gives B both an inbound edge, A-B, and an outbound edge, B-C. The crash// requires B to first learn the preimage from C, then later learn the same preimage again// from the B-C monitor after C has gone on-chain.let(payment_preimage, payment_hash, ..) =
                      route_payment(&nodes[0],&[&nodes[1],&nodes[2]],1_000_000);
                      nodes[2].node.claim_funds(payment_preimage);check_added_monitors(&nodes[2],1);expect_payment_claimed!(nodes[2], payment_hash,1_000_000);// Leave B's A-B preimage monitor update in flight. This is the critical persistence state:// B has enough information to claim from A, but the monitor update that makes the preimage// durable for the inbound edge has not completed.
                      chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress);let cs_updates = get_htlc_update_msgs(&nodes[2],&node_b_id);
                      nodes[1].node.handle_update_fulfill_htlc(node_c_id, cs_updates.update_fulfill_htlcs[0].clone());check_added_monitors(&nodes[1],1);// If B emitted the fulfill here, the A-B monitor update would not be blocked and the later// duplicate claim would free inline instead of being parked for monitor completion.assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());// C's local commitment still contains the HTLC because B has only received the fulfill, not// completed C's commitment_signed exchange. Mining C's commitment plus HTLC-success makes B's// monitor observe the same preimage through the on-chain path.let cs_txn = get_local_commitment_txn!(nodes[2], chan_bc.2);assert!(cs_txn.len() >= 2,"Expected commitment + HTLC-success tx, got {}", cs_txn.len());mine_transaction(&nodes[1],&cs_txn[0]);check_closed_broadcast(&nodes[1],1,true);check_added_monitors(&nodes[1],1);let events = nodes[1].node.get_and_clear_pending_events();assert!(events.iter().any(|e| matches!(e,Event::ChannelClosed{ .. })));mine_transaction(&nodes[1],&cs_txn[1]);connect_blocks(&nodes[1],ANTI_REORG_DELAY);// Draining events here forces B to process the monitor HTLC event before serialization. The// replayed preimage is a duplicate against A-B, but A-B still has an in-flight monitor update,// so the duplicate-free action is queued instead of run immediately.assert!(nodes[1].node.get_and_clear_pending_events().is_empty());// Reload with the manager serialized after the duplicate action was queued. During read, the// queued action is found in monitor_update_blocked_actions even though that action class is// expected to be purely in-memory and non-persistent.let mon_ab = get_monitor!(nodes[1], chan_id_ab).encode();let mon_bc = get_monitor!(nodes[1], chan_bc.2).encode();let manager_b = nodes[1].node.encode();reload_node!(nodes[1],&manager_b,&[&mon_ab,&mon_bc], persister, chain_mon, node_b_reload);}

                      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

                          Persisted duplicate-claim completion action can trip reload assertion #4738

                          Description

                          @joostjager

                          Note: This draft was AI-generated and may contain mistakes. Reproduces on main (7b63ffa)

                          This was found while triaging a rare crash from chanmon consistency fuzzing. The
                          fuzz case was reduced to a focused functional test that does not use splice
                          commands.

                          Problem

                          MonitorUpdateCompletionAction::FreeDuplicateClaimImmediately is documented as
                          an in-memory action that should not be written to disk. In one duplicate HTLC
                          claim path, however, it can be pushed into monitor_update_blocked_actions while
                          a previous-hop monitor update is still in flight. If the ChannelManager is
                          serialized in that state and then reloaded, deserialization finds the queued
                          FreeDuplicateClaimImmediately action and hits the debug assertion:

                          Non-event-generating channel freeing should not appear in our queue
                          

                          Affected behavior / minimal sequence

                          The minimized functional reproducer uses three nodes, A, B, and C:

                          1. Route a payment A -> B -> C.
                          2. C claims the payment and sends B an update_fulfill_htlc.
                          3. B learns the preimage and tries to claim the previous-hop HTLC from A.
                          4. B's A-B monitor update is left InProgress, so B does not yet emit the
                            fulfill back to A.
                          5. C goes on-chain with its commitment and HTLC-success transaction.
                          6. B's B-C monitor observes the same preimage on-chain and replays the claim.
                          7. The replay is a duplicate claim against A-B, but A-B still has an in-flight
                            monitor update, so the duplicate-free action is queued.
                          8. Serializing and reloading B hits the assertion during ChannelManager read.

                          Expected behavior

                          Reload should not assert on valid duplicate monitor replay. Either the
                          duplicate-free action should be handled immediately, or it should be represented
                          in persisted state in a way that the reload path accepts and processes
                          correctly.

                          Actual behavior

                          The duplicate-free action can be serialized in monitor_update_blocked_actions.
                          On reload, the read path treats that variant as invalid queued state and triggers
                          the debug assertion above.

                          Why this looks like a project bug

                          This does not appear to depend on fuzz-harness-only behavior. The focused
                          reproducer uses normal functional-test helpers for channels, payments, async
                          monitor persistence, block connection, and node reload. It also does not require
                          splicing.

                          The on-chain duplicate preimage path is expected in general: a monitor can later
                          report an HTLC resolution that the ChannelManager already learned offchain.
                          The bug appears to be the combination of that duplicate claim with an in-flight
                          previous-hop monitor update, which causes an action documented as non-persistent
                          to enter serialized manager state.

                          Impact

                          The verified impact is a debug-assertion crash on reload. The assertion is a
                          debug_assert!, so the exact panic is not expected in normal release builds.

                          A remote downstream peer can influence the payment, message, and on-chain parts
                          of the sequence, but the exact state also requires local timing: async monitor
                          persistence must leave the previous-hop update in flight, and the node must
                          serialize and reload in that window. I have not verified funds loss. I also have
                          not yet verified whether release builds always recover cleanly, or whether they
                          can leave a channel temporarily or permanently blocked after the invalid action
                          is persisted.

                          Focused reproducer test

                          #[test]#[should_panic(expected = "Non-event-generating channel freeing should not appear in our queue")]fntest_duplicate_onchain_claim_reloaded_with_inflight_prev_hop_update(){let chanmon_cfgs = create_chanmon_cfgs(3);let node_cfgs = create_node_cfgs(3,&chanmon_cfgs);let persister;let chain_mon;let node_b_reload;let legacy_cfg = test_legacy_channel_config();let node_chanmgrs = create_node_chanmgrs(3,&node_cfgs,&[Some(legacy_cfg.clone()),Some(legacy_cfg.clone()),Some(legacy_cfg)],);letmut nodes = create_network(3,&node_cfgs,&node_chanmgrs);let node_b_id = nodes[1].node.get_our_node_id();let node_c_id = nodes[2].node.get_our_node_id();let chan_id_ab = create_announced_chan_between_nodes(&nodes,0,1).2;let chan_bc = create_announced_chan_between_nodes(&nodes,1,2);// This payment gives B both an inbound edge, A-B, and an outbound edge, B-C. The crash// requires B to first learn the preimage from C, then later learn the same preimage again// from the B-C monitor after C has gone on-chain.let(payment_preimage, payment_hash, ..) =
                          route_payment(&nodes[0],&[&nodes[1],&nodes[2]],1_000_000);
                          nodes[2].node.claim_funds(payment_preimage);check_added_monitors(&nodes[2],1);expect_payment_claimed!(nodes[2], payment_hash,1_000_000);// Leave B's A-B preimage monitor update in flight. This is the critical persistence state:// B has enough information to claim from A, but the monitor update that makes the preimage// durable for the inbound edge has not completed.
                          chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress);let cs_updates = get_htlc_update_msgs(&nodes[2],&node_b_id);
                          nodes[1].node.handle_update_fulfill_htlc(node_c_id, cs_updates.update_fulfill_htlcs[0].clone());check_added_monitors(&nodes[1],1);// If B emitted the fulfill here, the A-B monitor update would not be blocked and the later// duplicate claim would free inline instead of being parked for monitor completion.assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());// C's local commitment still contains the HTLC because B has only received the fulfill, not// completed C's commitment_signed exchange. Mining C's commitment plus HTLC-success makes B's// monitor observe the same preimage through the on-chain path.let cs_txn = get_local_commitment_txn!(nodes[2], chan_bc.2);assert!(cs_txn.len() >= 2,"Expected commitment + HTLC-success tx, got {}", cs_txn.len());mine_transaction(&nodes[1],&cs_txn[0]);check_closed_broadcast(&nodes[1],1,true);check_added_monitors(&nodes[1],1);let events = nodes[1].node.get_and_clear_pending_events();assert!(events.iter().any(|e| matches!(e,Event::ChannelClosed{ .. })));mine_transaction(&nodes[1],&cs_txn[1]);connect_blocks(&nodes[1],ANTI_REORG_DELAY);// Draining events here forces B to process the monitor HTLC event before serialization. The// replayed preimage is a duplicate against A-B, but A-B still has an in-flight monitor update,// so the duplicate-free action is queued instead of run immediately.assert!(nodes[1].node.get_and_clear_pending_events().is_empty());// Reload with the manager serialized after the duplicate action was queued. During read, the// queued action is found in monitor_update_blocked_actions even though that action class is// expected to be purely in-memory and non-persistent.let mon_ab = get_monitor!(nodes[1], chan_id_ab).encode();let mon_bc = get_monitor!(nodes[1], chan_bc.2).encode();let manager_b = nodes[1].node.encode();reload_node!(nodes[1],&manager_b,&[&mon_ab,&mon_bc], persister, chain_mon, node_b_reload);}

                          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

                              Persisted duplicate-claim completion action can trip reload assertion #4738

                              Description

                              @joostjager

                              Note: This draft was AI-generated and may contain mistakes. Reproduces on main (7b63ffa)

                              This was found while triaging a rare crash from chanmon consistency fuzzing. The
                              fuzz case was reduced to a focused functional test that does not use splice
                              commands.

                              Problem

                              MonitorUpdateCompletionAction::FreeDuplicateClaimImmediately is documented as
                              an in-memory action that should not be written to disk. In one duplicate HTLC
                              claim path, however, it can be pushed into monitor_update_blocked_actions while
                              a previous-hop monitor update is still in flight. If the ChannelManager is
                              serialized in that state and then reloaded, deserialization finds the queued
                              FreeDuplicateClaimImmediately action and hits the debug assertion:

                              Non-event-generating channel freeing should not appear in our queue
                              

                              Affected behavior / minimal sequence

                              The minimized functional reproducer uses three nodes, A, B, and C:

                              1. Route a payment A -> B -> C.
                              2. C claims the payment and sends B an update_fulfill_htlc.
                              3. B learns the preimage and tries to claim the previous-hop HTLC from A.
                              4. B's A-B monitor update is left InProgress, so B does not yet emit the
                                fulfill back to A.
                              5. C goes on-chain with its commitment and HTLC-success transaction.
                              6. B's B-C monitor observes the same preimage on-chain and replays the claim.
                              7. The replay is a duplicate claim against A-B, but A-B still has an in-flight
                                monitor update, so the duplicate-free action is queued.
                              8. Serializing and reloading B hits the assertion during ChannelManager read.

                              Expected behavior

                              Reload should not assert on valid duplicate monitor replay. Either the
                              duplicate-free action should be handled immediately, or it should be represented
                              in persisted state in a way that the reload path accepts and processes
                              correctly.

                              Actual behavior

                              The duplicate-free action can be serialized in monitor_update_blocked_actions.
                              On reload, the read path treats that variant as invalid queued state and triggers
                              the debug assertion above.

                              Why this looks like a project bug

                              This does not appear to depend on fuzz-harness-only behavior. The focused
                              reproducer uses normal functional-test helpers for channels, payments, async
                              monitor persistence, block connection, and node reload. It also does not require
                              splicing.

                              The on-chain duplicate preimage path is expected in general: a monitor can later
                              report an HTLC resolution that the ChannelManager already learned offchain.
                              The bug appears to be the combination of that duplicate claim with an in-flight
                              previous-hop monitor update, which causes an action documented as non-persistent
                              to enter serialized manager state.

                              Impact

                              The verified impact is a debug-assertion crash on reload. The assertion is a
                              debug_assert!, so the exact panic is not expected in normal release builds.

                              A remote downstream peer can influence the payment, message, and on-chain parts
                              of the sequence, but the exact state also requires local timing: async monitor
                              persistence must leave the previous-hop update in flight, and the node must
                              serialize and reload in that window. I have not verified funds loss. I also have
                              not yet verified whether release builds always recover cleanly, or whether they
                              can leave a channel temporarily or permanently blocked after the invalid action
                              is persisted.

                              Focused reproducer test

                              #[test]#[should_panic(expected = "Non-event-generating channel freeing should not appear in our queue")]fntest_duplicate_onchain_claim_reloaded_with_inflight_prev_hop_update(){let chanmon_cfgs = create_chanmon_cfgs(3);let node_cfgs = create_node_cfgs(3,&chanmon_cfgs);let persister;let chain_mon;let node_b_reload;let legacy_cfg = test_legacy_channel_config();let node_chanmgrs = create_node_chanmgrs(3,&node_cfgs,&[Some(legacy_cfg.clone()),Some(legacy_cfg.clone()),Some(legacy_cfg)],);letmut nodes = create_network(3,&node_cfgs,&node_chanmgrs);let node_b_id = nodes[1].node.get_our_node_id();let node_c_id = nodes[2].node.get_our_node_id();let chan_id_ab = create_announced_chan_between_nodes(&nodes,0,1).2;let chan_bc = create_announced_chan_between_nodes(&nodes,1,2);// This payment gives B both an inbound edge, A-B, and an outbound edge, B-C. The crash// requires B to first learn the preimage from C, then later learn the same preimage again// from the B-C monitor after C has gone on-chain.let(payment_preimage, payment_hash, ..) =
                              route_payment(&nodes[0],&[&nodes[1],&nodes[2]],1_000_000);
                              nodes[2].node.claim_funds(payment_preimage);check_added_monitors(&nodes[2],1);expect_payment_claimed!(nodes[2], payment_hash,1_000_000);// Leave B's A-B preimage monitor update in flight. This is the critical persistence state:// B has enough information to claim from A, but the monitor update that makes the preimage// durable for the inbound edge has not completed.
                              chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress);let cs_updates = get_htlc_update_msgs(&nodes[2],&node_b_id);
                              nodes[1].node.handle_update_fulfill_htlc(node_c_id, cs_updates.update_fulfill_htlcs[0].clone());check_added_monitors(&nodes[1],1);// If B emitted the fulfill here, the A-B monitor update would not be blocked and the later// duplicate claim would free inline instead of being parked for monitor completion.assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());// C's local commitment still contains the HTLC because B has only received the fulfill, not// completed C's commitment_signed exchange. Mining C's commitment plus HTLC-success makes B's// monitor observe the same preimage through the on-chain path.let cs_txn = get_local_commitment_txn!(nodes[2], chan_bc.2);assert!(cs_txn.len() >= 2,"Expected commitment + HTLC-success tx, got {}", cs_txn.len());mine_transaction(&nodes[1],&cs_txn[0]);check_closed_broadcast(&nodes[1],1,true);check_added_monitors(&nodes[1],1);let events = nodes[1].node.get_and_clear_pending_events();assert!(events.iter().any(|e| matches!(e,Event::ChannelClosed{ .. })));mine_transaction(&nodes[1],&cs_txn[1]);connect_blocks(&nodes[1],ANTI_REORG_DELAY);// Draining events here forces B to process the monitor HTLC event before serialization. The// replayed preimage is a duplicate against A-B, but A-B still has an in-flight monitor update,// so the duplicate-free action is queued instead of run immediately.assert!(nodes[1].node.get_and_clear_pending_events().is_empty());// Reload with the manager serialized after the duplicate action was queued. During read, the// queued action is found in monitor_update_blocked_actions even though that action class is// expected to be purely in-memory and non-persistent.let mon_ab = get_monitor!(nodes[1], chan_id_ab).encode();let mon_bc = get_monitor!(nodes[1], chan_bc.2).encode();let manager_b = nodes[1].node.encode();reload_node!(nodes[1],&manager_b,&[&mon_ab,&mon_bc], persister, chain_mon, node_b_reload);}

                              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