Don't double-forward inbounds resolved in holding cell
What changed, and why it matters
This commit fixes a bug in the Lightning Dev Kit's rust-lightning implementation where, after restarting a forwarding node, an already-resolved inbound payment could be forwarded a second time. The bug occurred because the code rebuilt a list of pending payments from channel data on startup but did not exclude payments whose resolution was waiting in a temporary 'holding cell.' The fix filters out such payments so they are not double-forwarded. The commit message says the buggy code was never shipped in a release.
Review the fix for completeness (e.g., ensure all holding-cell resolution variants are covered and that no other pending-HTLC reconstruction paths have the same issue). Run the new regression test and consider backporting if the affected code exists in any shipped release, despite the commit message claiming it was not shipped.
Security signals we found
Double-forwarding of HTLCs after node restart
State-reconstruction bug during deserialization
Holding-cell resolution not pruned from pending HTLC set
Potential funds-at-risk / channel inconsistency if exploited
Regression test added for reload path
Evidence from the diff
The patch renames get_inbound_committed_update_adds to inbound_committed_unresolved_htlcs and adds a closure that checks whether a given HTLC id already has a resolution (claim, fail, or fail-malformed) sitting in holding_cell_htlc_updates. If so, the HTLC is skipped when reconstructing ChannelManager::decode_update_add_htlcs during deserialization. A regression test simulates a forwarding node whose outbound HTLC is removed and whose inbound resolution is held in the holding cell, then reloads the node and verifies no second forward occurs.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/reload_tests.rsInspect captured patch +99 / −2
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 3678ccb..622227b 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -7839,12 +7839,28 @@ where
}
/// Useful for reconstructing the set of pending HTLCs when deserializing the `ChannelManager`.
- pub(super) fn get_inbound_committed_update_adds(&self) -> Vec<msgs::UpdateAddHTLC> {
+ pub(super) fn inbound_committed_unresolved_htlcs(&self) -> Vec<msgs::UpdateAddHTLC> {
+ // We don't want to return an HTLC as needing processing if it already has a resolution that's
+ // pending in the holding cell.
+ let htlc_resolution_in_holding_cell = |id: u64| -> bool {
+ self.context.holding_cell_htlc_updates.iter().any(|holding_cell_htlc| {
+ match holding_cell_htlc {
+ HTLCUpdateAwaitingACK::ClaimHTLC { htlc_id, .. } => *htlc_id == id,
+ HTLCUpdateAwaitingACK::FailHTLC { htlc_id, .. } => *htlc_id == id,
+ HTLCUpdateAwaitingACK::FailMalformedHTLC { htlc_id, .. } => *htlc_id == id,
+ HTLCUpdateAwaitingACK::AddHTLC { .. } => false,
+ }
+ })
+ };
+
self.context
.pending_inbound_htlcs
.iter()
.filter_map(|htlc| match htlc.state {
InboundHTLCState::Committed { ref update_add_htlc_opt } => {
+ if htlc_resolution_in_holding_cell(htlc.htlc_id) {
+ return None;
+ }
update_add_htlc_opt.clone()
},
_ => None,
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index a0fb136..2665bf1 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -18558,7 +18558,7 @@ impl<
if let Some(chan) = peer_state.channel_by_id.get(channel_id) {
if let Some(funded_chan) = chan.as_funded() {
let inbound_committed_update_adds =
- funded_chan.get_inbound_committed_update_adds();
+ funded_chan.inbound_committed_unresolved_htlcs();
if !inbound_committed_update_adds.is_empty() {
// Reconstruct `ChannelManager::decode_update_add_htlcs` from the serialized
// `Channel`, as part of removing the requirement to regularly persist the
diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs
index e6061cc..826fdbf 100644
--- a/lightning/src/ln/reload_tests.rs
+++ b/lightning/src/ln/reload_tests.rs
@@ -1758,3 +1758,84 @@ fn test_hold_completed_inflight_monitor_updates_upon_manager_reload() {
reconnect_nodes(reconnect_args);
}
+#[test]
+fn outbound_removed_holding_cell_resolved_no_double_forward() {
+ // Test that if a forwarding node has an HTLC that is fully removed on the outbound edge
+ // but where the inbound edge resolution is in the holding cell, and we reload the node in this
+ // state, that node will not double-forward the HTLC.
+
+ let chanmon_cfgs = create_chanmon_cfgs(3);
+ let node_cfgs = create_node_cfgs(3, &chanmon_cfgs);
+ let persister;
+ let new_chain_monitor;
+ let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]);
+ let nodes_1_deserialized;
+ let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs);
+
+ let node_0_id = nodes[0].node.get_our_node_id();
+ let node_1_id = nodes[1].node.get_our_node_id();
+ let node_2_id = nodes[2].node.get_our_node_id();
+
+ let chan_0_1 = create_announced_chan_between_nodes(&nodes, 0, 1);
+ let chan_1_2 = create_announced_chan_between_nodes(&nodes, 1, 2);
+
+ let chan_id_0_1 = chan_0_1.2;
+ let chan_id_1_2 = chan_1_2.2;
+
+ // Send a payment from nodes[0] to nodes[2] via nodes[1].
+ let (route, payment_hash, payment_preimage, payment_secret) =
+ get_route_and_payment_hash!(nodes[0], nodes[2], 1_000_000);
+ send_along_route_with_secret(
+ &nodes[0], route, &[&[&nodes[1], &nodes[2]]], 1_000_000, payment_hash, payment_secret,
+ );
+
+ // Claim the payment on nodes[2].
+ nodes[2].node.claim_funds(payment_preimage);
+ check_added_monitors(&nodes[2], 1);
+ expect_payment_claimed!(nodes[2], payment_hash, 1_000_000);
+
+ // Disconnect nodes[0] from nodes[1] BEFORE processing the fulfill.
+ // This forces the inbound fulfill resolution go to into nodes[1]'s holding cell for the inbound
+ // channel.
+ nodes[0].node.peer_disconnected(node_1_id);
+ nodes[1].node.peer_disconnected(node_0_id);
+
+ // Process the fulfill from nodes[2] to nodes[1].
+ let updates_2_1 = get_htlc_update_msgs(&nodes[2], &node_1_id);
+ nodes[1].node.handle_update_fulfill_htlc(node_2_id, updates_2_1.update_fulfill_htlcs[0].clone());
+ check_added_monitors(&nodes[1], 1);
+ do_commitment_signed_dance(&nodes[1], &nodes[2], &updates_2_1.commitment_signed, false, false);
+ expect_payment_forwarded!(nodes[1], nodes[0], nodes[2], Some(1000), false, false);
+
+ // At this point:
+ // - The outbound HTLC nodes[1]->nodes[2] is resolved and removed
+ // - The inbound HTLC nodes[0]->nodes[1] is still in a Committed state, with the fulfill
+ // resolution in nodes[1]'s chan_0_1 holding cell
+ let node_1_serialized = nodes[1].node.encode();
+ let mon_0_1_serialized = get_monitor!(nodes[1], chan_id_0_1).encode();
+ let mon_1_2_serialized = get_monitor!(nodes[1], chan_id_1_2).encode();
+
+ // Reload nodes[1].
+ // During deserialization, we previously would have not noticed that the nodes[0]<>nodes[1] HTLC
+ // had a resolution pending in the holding cell, and reconstructed the ChannelManager's pending
+ // HTLC state indicating that the HTLC still needed to be forwarded to the outbound edge.
+ reload_node!(
+ nodes[1],
+ node_1_serialized,
+ &[&mon_0_1_serialized, &mon_1_2_serialized],
+ persister,
+ new_chain_monitor,
+ nodes_1_deserialized
+ );
+
+ // Check that nodes[1] doesn't double-forward the HTLC.
+ nodes[1].node.process_pending_htlc_forwards();
+
+ // Reconnect nodes[1] to nodes[0]. The claim should be in nodes[1]'s holding cell.
+ let mut reconnect_args = ReconnectArgs::new(&nodes[1], &nodes[0]);
+ reconnect_args.pending_cell_htlc_claims = (0, 1);
+ reconnect_nodes(reconnect_args);
+
+ // nodes[0] should now have received the fulfill and generate PaymentSent.
+ expect_payment_sent(&nodes[0], payment_preimage, None, true, true);
+}
Why this scored 56/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.