Avoid panic when reorged claims cannot merge
What changed, and why it matters
This commit fixes a crash bug in the Lightning Dev Kit's on-chain transaction handler. During a deep blockchain reorganization, a previously settled HTLC claim could be 'resurrected' at a block height where it could no longer be combined with a surviving claim. The old code used an assertion that assumed merging would always succeed, so the node would panic and shut down. The fix checks whether merging is possible first; if not, it preserves the resurrected claim so it can be broadcast independently. A regression test reproduces the exact reorg scenario.
Treat this as a security fix and include it in the next maintenance release. Nodes running affected versions should upgrade, especially those operating in environments with non-negligible reorg risk or adversarial counterparty behavior. The regression test should be run in CI.
Security signals we found
Assertion/panic in reorg handling path
Deep blockchain reorg as trigger condition
HTLC claim resurrection after reorg
Split aggregated package becomes unmergeable due to differing locktimes
Denial-of-service vector: remote party can cause local node crash via on-chain actions
Regression test added for the panic condition
Evidence from the diff
In OnchainTxHandler::blocks_disconnected, when handling a reorg that revives an HTLC package, the code previously asserted that request.merge_package(package, new_best_height + 1) would succeed. A deep reorg can split an aggregated claim: the counterparty’s preimage claim for one HTLC removes that input from the aggregated timeout claim, and if the reorg rewinds the chain far enough, the resurrected package’s locktime is satisfied while the surviving claim’s locktime is not, making them unmergeable. The assertion then fails and the node panics. The patch adds can_merge_with guard; on failure it stores the package in locktimed_packages keyed by its locktime, deferring it to normal block connection processing. A new regression test in reorg_tests.rs exercises the exact sequence: two outbound HTLCs with expiries two blocks apart, aggregated timeout claim, partial preimage claim by counterparty, deep reorg, and independent broadcast of the resurrected claim.
Changed components
lightning/src/chain/onchaintx.rsOnchainTxHandler::blocks_disconnectedlocktimed_packagespending_claim_requestsclaimable_outpointsInspect captured patch +219 / −5
diff --git a/lightning/src/chain/onchaintx.rs b/lightning/src/chain/onchaintx.rs
index 75a4e19..6b61009 100644
--- a/lightning/src/chain/onchaintx.rs
+++ b/lightning/src/chain/onchaintx.rs
@@ -1180,14 +1180,24 @@ impl<ChannelSigner: EcdsaChannelSigner> OnchainTxHandler<ChannelSigner> {
if let Some(pending_claim) = self.claimable_outpoints.get(package.outpoints()[0]) {
if let Some(request) = self.pending_claim_requests.get_mut(&pending_claim.0) {
- assert!(request.merge_package(package, new_best_height + 1).is_ok());
- // Using a HashMap guarantee us than if we have multiple outpoints getting
- // resurrected only one bump claim tx is going to be broadcast
- bump_candidates.insert(pending_claim.clone(), request.clone());
+ if request.can_merge_with(&package, new_best_height + 1) {
+ assert!(request.merge_package(package, new_best_height + 1).is_ok());
+ // Using a HashMap guarantee us than if we have multiple outpoints getting
+ // resurrected only one bump claim tx is going to be broadcast
+ bump_candidates.insert(pending_claim.clone(), request.clone());
+ continue;
+ }
}
}
+ // While we really should push the new package through the generation below
+ // (as it is not, in fact, locktimed to a later height), to avoid additional
+ // complexity we shove the package in the locktimed set instead, which will
+ // result in it getting handled when the reorg processing finishes and the
+ // new tip is connected.
+ let package_locktime = package.package_locktime(0);
+ self.locktimed_packages.entry(package_locktime).or_default().push(package);
},
- _ => {},
+ OnchainEvent::Claim { .. } => {},
}
} else {
self.onchain_events_awaiting_threshold_conf.push(entry);
diff --git a/lightning/src/ln/reorg_tests.rs b/lightning/src/ln/reorg_tests.rs
index 5b51601..5a4bf9e 100644
--- a/lightning/src/ln/reorg_tests.rs
+++ b/lightning/src/ln/reorg_tests.rs
@@ -1357,3 +1357,207 @@ fn test_split_htlc_expiry_tracking() {
do_test_split_htlc_expiry_tracking(true, false, true);
do_test_split_htlc_expiry_tracking(false, false, true);
}
+
+fn do_test_reorg_resurrect_split_htlc_package_with_future_locktime(style: ConnectStyle) {
+ // Exercises a failed `request.merge_package(..)` in `OnchainTxHandler::blocks_disconnected`.
+ //
+ // Two of our outbound HTLCs land on the counterparty's commitment with `cltv_expiry` two
+ // blocks apart. If the commitment confirms at a height at or past the *later* expiry, both
+ // timeout claims are immediately spendable, so their `package_locktime` agree and they
+ // aggregate into a single claim. The counterparty then claims only the earlier-expiring HTLC
+ // with its preimage, which splits that input back out into a `ContentiousOutpoint`. On a reorg
+ // back below the later expiry, the split-off package's locktime requirement has been met but
+ // the surviving one's has not, so the two are no longer mergeable and the assert's condition
+ // is false.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ // A legacy (non-anchor) channel is required: with anchors the counterparty aggregates both of
+ // its HTLC-success claims into one transaction, which would spend *every* outpoint in our
+ // claim and take the `is_claim_subset_of_tx` path instead of splitting.
+ let legacy_cfg = test_legacy_channel_config();
+ let node_chanmgrs =
+ create_node_chanmgrs(2, &node_cfgs, &[Some(legacy_cfg.clone()), Some(legacy_cfg)]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+ *nodes[0].connect_style.borrow_mut() = style;
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let (_, _, chan_id, funding_tx) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 0);
+
+ // Two outbound HTLCs with different amounts (so we can tell their commitment outputs apart)
+ // and `cltv_expiry` exactly two blocks apart.
+ let amt_a_msat = 100_000_000;
+ let amt_b_msat = 200_000_000;
+ let (preimage_a, payment_hash_a, ..) = route_payment(&nodes[0], &[&nodes[1]], amt_a_msat);
+ connect_blocks(&nodes[0], 2);
+ connect_blocks(&nodes[1], 2);
+ let (preimage_b, payment_hash_b, ..) = route_payment(&nodes[0], &[&nodes[1]], amt_b_msat);
+
+ let (cltv_a, cltv_b) = {
+ let chans = nodes[0].node.list_channels();
+ assert_eq!(chans.len(), 1);
+ let mut htlcs = chans[0].pending_outbound_htlcs.clone();
+ assert_eq!(htlcs.len(), 2, "{htlcs:?}");
+ htlcs.sort_by_key(|h| h.cltv_expiry);
+ assert_eq!(htlcs[0].amount_msat, amt_a_msat);
+ assert_eq!(htlcs[1].amount_msat, amt_b_msat);
+ (htlcs[0].cltv_expiry, htlcs[1].cltv_expiry)
+ };
+ println!("[repro] A height after routing = {}", nodes[0].best_block_info().1);
+ println!("[repro] cltv_a = {cltv_a}, cltv_b = {cltv_b}");
+ assert_eq!(cltv_b, cltv_a + 2, "need the expiries exactly 2 apart");
+
+ nodes[0].node.peer_disconnected(node_id_1);
+ nodes[1].node.peer_disconnected(node_id_0);
+
+ // Give node B both preimages so it will claim both HTLCs on-chain.
+ nodes[1].node.claim_funds(preimage_a);
+ expect_payment_claimed!(nodes[1], payment_hash_a, amt_a_msat);
+ nodes[1].node.claim_funds(preimage_b);
+ expect_payment_claimed!(nodes[1], payment_hash_b, amt_b_msat);
+ check_added_monitors(&nodes[1], 2);
+
+ // Node B force-closes, giving us its commitment transaction.
+ let err = "Channel force-closed".to_string();
+ nodes[1].node.force_close_broadcasting_latest_txn(&chan_id, &node_id_0, err).unwrap();
+ check_closed_broadcast(&nodes[1], 1, false);
+ check_added_monitors(&nodes[1], 1);
+ let message = "Channel force-closed".to_owned();
+ let reason = ClosureReason::HolderForceClosed { broadcasted_latest_txn: Some(true), message };
+ check_closed_event(&nodes[1], 1, reason, &[node_id_0], 10_000_000);
+
+ // On a legacy channel node B knows both preimages, so it broadcasts its commitment together
+ // with a separate, pre-signed HTLC-success transaction per HTLC.
+ let mut bs_txn = nodes[1].tx_broadcaster.txn_broadcast();
+ println!("[repro] node B broadcast {} txn on force-close", bs_txn.len());
+ assert_eq!(bs_txn.len(), 3, "{bs_txn:?}");
+ let commitment_tx = bs_txn.remove(0);
+ check_spends!(commitment_tx, funding_tx);
+ let commitment_txid = commitment_tx.compute_txid();
+ for tx in bs_txn.iter() {
+ check_spends!(tx, commitment_tx);
+ assert_eq!(tx.input.len(), 1);
+ }
+ // Take only the HTLC-success transaction for the *earlier*-expiring HTLC, identified by the
+ // value of the commitment output it spends.
+ let partial_claim_tx = bs_txn
+ .into_iter()
+ .find(|tx| {
+ tx.input.len() == 1
+ && tx.input[0].previous_output.txid == commitment_txid
+ && commitment_tx.output[tx.input[0].previous_output.vout as usize].value.to_sat()
+ == amt_a_msat / 1000
+ })
+ .expect("node B should have a single-input HTLC-success tx for the earlier HTLC");
+ println!(
+ "[repro] node B partial claim spends {}:{} (value {} sat)",
+ partial_claim_tx.input[0].previous_output.txid,
+ partial_claim_tx.input[0].previous_output.vout,
+ commitment_tx.output[partial_claim_tx.input[0].previous_output.vout as usize]
+ .value
+ .to_sat(),
+ );
+
+ // Advance node A so that the block containing the commitment lands at exactly `cltv_b`. Note
+ // this stays below `cltv_a + LATENCY_GRACE_PERIOD_BLOCKS`, so node A does not force-close on
+ // its own first.
+ let a_height = nodes[0].best_block_info().1;
+ assert!(a_height < cltv_b - 1, "a_height {a_height} cltv_b {cltv_b}");
+ connect_blocks(&nodes[0], cltv_b - 1 - a_height);
+ assert_eq!(nodes[0].best_block_info().1, cltv_b - 1);
+ assert_eq!(nodes[0].tx_broadcaster.txn_broadcast().len(), 0);
+
+ mine_transaction(&nodes[0], &commitment_tx);
+ let commitment_conf_height = nodes[0].best_block_info().1;
+ println!("[repro] commitment confirmed on A at height {commitment_conf_height}");
+ assert_eq!(commitment_conf_height, cltv_b);
+ check_closed_broadcast(&nodes[0], 1, false);
+ let reason = ClosureReason::CommitmentTxConfirmed;
+ check_closed_event(&nodes[0], 1, reason, &[node_id_1], 10_000_000);
+ check_added_monitors(&nodes[0], 1);
+
+ // Both timeout claims must have aggregated into a single transaction, otherwise the path we
+ // are trying to reach does not exist.
+ {
+ let txn = nodes[0].tx_broadcaster.txn_broadcast();
+ println!("[repro] node A broadcast {} txn when the commitment confirmed:", txn.len());
+ for tx in txn.iter() {
+ println!(
+ "[repro] txid {} locktime {} inputs {:?}",
+ tx.compute_txid(),
+ tx.lock_time.to_consensus_u32(),
+ tx.input.iter().map(|i| i.previous_output).collect::<Vec<_>>(),
+ );
+ }
+ let aggregated = txn
+ .iter()
+ .find(|tx| {
+ tx.input.len() == 2
+ && tx.input.iter().all(|i| i.previous_output.txid == commitment_txid)
+ })
+ .expect("node A must aggregate both HTLC timeout claims into one transaction");
+ assert_eq!(aggregated.lock_time.to_consensus_u32(), cltv_b);
+ }
+
+ // Now confirm the counterparty's preimage claim of the earlier HTLC, splitting it back out of
+ // our aggregated claim.
+ mine_transaction(&nodes[0], &partial_claim_tx);
+ let spend_conf_height = nodes[0].best_block_info().1;
+ println!("[repro] node B's partial claim confirmed on A at height {spend_conf_height}");
+ {
+ let txn = nodes[0].tx_broadcaster.txn_broadcast();
+ println!("[repro] node A broadcast {} txn after the split:", txn.len());
+ for tx in txn.iter() {
+ println!(
+ "[repro] txid {} locktime {} inputs {:?}",
+ tx.compute_txid(),
+ tx.lock_time.to_consensus_u32(),
+ tx.input.iter().map(|i| i.previous_output).collect::<Vec<_>>(),
+ );
+ }
+ }
+
+ // Node A learned both preimages from node B's on-chain claim, so drain the resulting payment
+ // events; we want the only possible failure below to be the one we are testing for.
+ nodes[0].node.get_and_clear_pending_events();
+ check_added_monitors(&nodes[0], 1);
+
+ // Reorg back to `cltv_b - 2`, which is `cltv_a`. The resurrected package (locktime `cltv_a`)
+ // is spendable, but the surviving request still requires locktime `cltv_b`.
+ let disconnect = spend_conf_height - cltv_b + 2;
+ let new_best_height = spend_conf_height - disconnect;
+ println!(
+ "[repro] disconnecting {disconnect} blocks: new_best_height will be {new_best_height} \
+ (cltv_a {cltv_a}, cltv_b {cltv_b}); re-merge is evaluated at {}",
+ new_best_height + 1,
+ );
+ assert!(cltv_a <= new_best_height);
+ assert!(new_best_height + 1 < cltv_b);
+ disconnect_blocks(&nodes[0], disconnect);
+ println!("[repro] survived the disconnect, height now {}", nodes[0].best_block_info().1);
+
+ // The failed merge must not discard the resurrected package. On the next block it should be
+ // registered as an independent claim and broadcast on its own.
+ connect_blocks(&nodes[0], 1);
+ let early_htlc_outpoint = partial_claim_tx.input[0].previous_output;
+ let txn = nodes[0].tx_broadcaster.txn_broadcast();
+ let independent_claim = txn
+ .iter()
+ .find(|tx| tx.input.len() == 1 && tx.input[0].previous_output == early_htlc_outpoint)
+ .expect("the resurrected HTLC package must be broadcast as an independent claim");
+ assert_eq!(independent_claim.lock_time.to_consensus_u32(), new_best_height + 1);
+}
+
+#[test]
+fn test_reorg_resurrect_split_htlc_package_with_future_locktime() {
+ // `FullBlockDisconnectionsSkippingViaListen` issues a single `blocks_disconnected` call with
+ // the fork point, which is what `lightning-block-sync` does after detecting a reorg. The
+ // styles which instead walk the chain backwards one block at a time re-merge successfully on
+ // the first (shallowest) disconnection, and the `*ReorgsOnlyTip` styles go through
+ // `transaction_unconfirmed`, which disconnects only to `entry.height - 1`; neither reaches the
+ // case below.
+ do_test_reorg_resurrect_split_htlc_package_with_future_locktime(
+ ConnectStyle::FullBlockDisconnectionsSkippingViaListen,
+ );
+}
Why this scored 57/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.