Store inbound committed update_adds in Channel
What changed, and why it matters
This commit changes how the Lightning Dev Kit stores information about incoming payment commitments inside a channel. It keeps a copy of the original 'update_add' message alongside committed inbound HTLCs so that, after a restart, the node can rebuild its internal payment-forwarding maps from Channel and ChannelMonitor data rather than relying on the separate ChannelManager persistence. The change is framed by the authors as a reliability improvement to avoid force-closure when ChannelManager and ChannelMonitor get out of sync on restart. It is not a direct security patch and does not appear to fix an active exploit, but it touches consensus-adjacent state and serialization, so mistakes here could affect funds safety.
Treat as a normal, well-documented refactor/state-improvement commit. Reviewers should verify that the new TLV field is read exactly once per Committed inbound HTLC, that the optional_vec length matches the number of Committed HTLCs, and that no duplicate or stale update_add data can be used during ChannelManager reconstruction in follow-up commits. No immediate security response is indicated by this commit alone.
Security signals we found
State-machine change for committed inbound HTLCs
New TLV serialization field (75) for inbound_committed_update_adds
Persistence format change with backward-compatible None default for old states
Explicit goal of reducing force-closure risk from out-of-sync ChannelManager/ChannelMonitor on restart
No direct bug fix or vulnerability disclosure language in commit message
Evidence from the diff
The patch converts InboundHTLCState::Committed from a unit variant into a struct variant carrying an optional msgs::UpdateAddHTLC. The stored update_add is populated when an inbound HTLC is promoted from AwaitingAnnouncedRemoteRevoke to Committed and is serialized/deserialized via a new TLV field (index 75) in the Channel persistence format. Existing test fixtures are updated to use the new variant with update_add_htlc_opt: None. The commit is explicitly described as groundwork for removing ChannelManager persistence and rebuilding state from ChannelMonitors.
Changed components
lightning/src/ln/channel.rsInboundHTLCState enum and serializationChannel persistence (write/read TLV)Inbound HTLC promotion and forwarding logicInspect captured patch +61 / −31
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 5b4ac4c..ed6f6ce 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -211,7 +211,14 @@ enum InboundHTLCState {
/// channel (before it can then get forwarded and/or removed).
/// Implies AwaitingRemoteRevoke.
AwaitingAnnouncedRemoteRevoke(InboundHTLCResolution),
- Committed,
+ /// An HTLC irrevocably committed in the latest commitment transaction, ready to be forwarded or
+ /// removed.
+ Committed {
+ /// Used to rebuild `ChannelManager` HTLC state on restart. Previously the manager would track
+ /// and persist all HTLC forwards and receives itself, but newer LDK versions avoid relying on
+ /// its persistence and instead reconstruct state based on `Channel` and `ChannelMonitor` data.
+ update_add_htlc_opt: Option<msgs::UpdateAddHTLC>,
+ },
/// Removed by us and a new commitment_signed was sent (if we were AwaitingRemoteRevoke when we
/// created it we would have put it in the holding cell instead). When they next revoke_and_ack
/// we'll drop it.
@@ -235,7 +242,7 @@ impl From<&InboundHTLCState> for Option<InboundHTLCStateDetails> {
InboundHTLCState::AwaitingAnnouncedRemoteRevoke(_) => {
Some(InboundHTLCStateDetails::AwaitingRemoteRevokeToAdd)
},
- InboundHTLCState::Committed => Some(InboundHTLCStateDetails::Committed),
+ InboundHTLCState::Committed { .. } => Some(InboundHTLCStateDetails::Committed),
InboundHTLCState::LocalRemoved(InboundHTLCRemovalReason::FailRelay(_)) => {
Some(InboundHTLCStateDetails::AwaitingRemoteRevokeToRemoveFail)
},
@@ -256,7 +263,7 @@ impl fmt::Display for InboundHTLCState {
InboundHTLCState::RemoteAnnounced(_) => write!(f, "RemoteAnnounced"),
InboundHTLCState::AwaitingRemoteRevokeToAnnounce(_) => write!(f, "AwaitingRemoteRevokeToAnnounce"),
InboundHTLCState::AwaitingAnnouncedRemoteRevoke(_) => write!(f, "AwaitingAnnouncedRemoteRevoke"),
- InboundHTLCState::Committed => write!(f, "Committed"),
+ InboundHTLCState::Committed { .. } => write!(f, "Committed"),
InboundHTLCState::LocalRemoved(_) => write!(f, "LocalRemoved"),
}
}
@@ -268,7 +275,7 @@ impl InboundHTLCState {
InboundHTLCState::RemoteAnnounced(_) => !generated_by_local,
InboundHTLCState::AwaitingRemoteRevokeToAnnounce(_) => !generated_by_local,
InboundHTLCState::AwaitingAnnouncedRemoteRevoke(_) => true,
- InboundHTLCState::Committed => true,
+ InboundHTLCState::Committed { .. } => true,
InboundHTLCState::LocalRemoved(_) => !generated_by_local,
}
}
@@ -296,7 +303,7 @@ impl InboundHTLCState {
},
InboundHTLCResolution::Resolved { .. } => false,
},
- InboundHTLCState::Committed | InboundHTLCState::LocalRemoved(_) => false,
+ InboundHTLCState::Committed { .. } | InboundHTLCState::LocalRemoved(_) => false,
}
}
}
@@ -4102,7 +4109,7 @@ where
if self.pending_inbound_htlcs.iter()
.any(|htlc| match htlc.state {
- InboundHTLCState::Committed => false,
+ InboundHTLCState::Committed { .. } => false,
// An HTLC removal from the local node is pending on the remote commitment.
InboundHTLCState::LocalRemoved(_) => true,
// An HTLC add from the remote node is pending on the local commitment.
@@ -4531,7 +4538,7 @@ where
(InboundHTLCState::RemoteAnnounced(..), _) => true,
(InboundHTLCState::AwaitingRemoteRevokeToAnnounce(..), _) => true,
(InboundHTLCState::AwaitingAnnouncedRemoteRevoke(..), _) => true,
- (InboundHTLCState::Committed, _) => true,
+ (InboundHTLCState::Committed { .. }, _) => true,
(InboundHTLCState::LocalRemoved(..), true) => true,
(InboundHTLCState::LocalRemoved(..), false) => false,
})
@@ -7320,7 +7327,7 @@ where
payment_preimage_arg
);
match htlc.state {
- InboundHTLCState::Committed => {},
+ InboundHTLCState::Committed { .. } => {},
InboundHTLCState::LocalRemoved(ref reason) => {
if let &InboundHTLCRemovalReason::Fulfill { .. } = reason {
} else {
@@ -7413,7 +7420,7 @@ where
{
let htlc = &mut self.context.pending_inbound_htlcs[pending_idx];
- if let InboundHTLCState::Committed = htlc.state {
+ if let InboundHTLCState::Committed { .. } = htlc.state {
} else {
debug_assert!(
false,
@@ -7548,7 +7555,7 @@ where
for (idx, htlc) in self.context.pending_inbound_htlcs.iter().enumerate() {
if htlc.htlc_id == htlc_id_arg {
match htlc.state {
- InboundHTLCState::Committed => {},
+ InboundHTLCState::Committed { .. } => {},
InboundHTLCState::LocalRemoved(_) => {
return Err(ChannelError::Ignore(format!("HTLC {} was already resolved", htlc.htlc_id)));
},
@@ -8716,7 +8723,7 @@ where
false
};
if swap {
- let mut state = InboundHTLCState::Committed;
+ let mut state = InboundHTLCState::Committed { update_add_htlc_opt: None };
mem::swap(&mut state, &mut htlc.state);
if let InboundHTLCState::AwaitingRemoteRevokeToAnnounce(resolution) = state {
@@ -8755,14 +8762,21 @@ where
PendingHTLCStatus::Forward(forward_info) => {
log_trace!(logger, " ...promoting inbound AwaitingAnnouncedRemoteRevoke {} to Committed, attempting to forward", &htlc.payment_hash);
to_forward_infos.push((forward_info, htlc.htlc_id));
- htlc.state = InboundHTLCState::Committed;
+ htlc.state = InboundHTLCState::Committed {
+ // HTLCs will only be in state `InboundHTLCResolution::Resolved` if they were
+ // received on an old pre-0.0.123 version of LDK. In this case, the HTLC is
+ // required to be resolved prior to upgrading to 0.1+ per CHANGELOG.md.
+ update_add_htlc_opt: None,
+ };
},
}
},
InboundHTLCResolution::Pending { update_add_htlc } => {
log_trace!(logger, " ...promoting inbound AwaitingAnnouncedRemoteRevoke {} to Committed", &htlc.payment_hash);
- pending_update_adds.push(update_add_htlc);
- htlc.state = InboundHTLCState::Committed;
+ pending_update_adds.push(update_add_htlc.clone());
+ htlc.state = InboundHTLCState::Committed {
+ update_add_htlc_opt: Some(update_add_htlc),
+ };
},
}
}
@@ -9297,7 +9311,7 @@ where
// in response to it yet, so don't touch it.
true
},
- InboundHTLCState::Committed => true,
+ InboundHTLCState::Committed { .. } => true,
InboundHTLCState::LocalRemoved(_) => {
// We (hopefully) sent a commitment_signed updating this HTLC (which we can
// re-transmit if needed) and they may have even sent a revoke_and_ack back
@@ -14518,6 +14532,7 @@ where
}
}
let mut removed_htlc_attribution_data: Vec<&Option<AttributionData>> = Vec::new();
+ let mut inbound_committed_update_adds: Vec<Option<msgs::UpdateAddHTLC>> = Vec::new();
(self.context.pending_inbound_htlcs.len() as u64 - dropped_inbound_htlcs).write(writer)?;
for htlc in self.context.pending_inbound_htlcs.iter() {
if let &InboundHTLCState::RemoteAnnounced(_) = &htlc.state {
@@ -14537,8 +14552,9 @@ where
2u8.write(writer)?;
htlc_resolution.write(writer)?;
},
- &InboundHTLCState::Committed => {
+ &InboundHTLCState::Committed { ref update_add_htlc_opt } => {
3u8.write(writer)?;
+ inbound_committed_update_adds.push(update_add_htlc_opt.clone());
},
&InboundHTLCState::LocalRemoved(ref removal_reason) => {
4u8.write(writer)?;
@@ -14914,6 +14930,7 @@ where
(69, holding_cell_held_htlc_flags, optional_vec), // Added in 0.2
(71, holder_commitment_point_previous_revoked, option), // Added in 0.3
(73, holder_commitment_point_last_revoked, option), // Added in 0.3
+ (75, inbound_committed_update_adds, optional_vec),
});
Ok(())
@@ -14997,7 +15014,7 @@ where
};
InboundHTLCState::AwaitingAnnouncedRemoteRevoke(resolution)
},
- 3 => InboundHTLCState::Committed,
+ 3 => InboundHTLCState::Committed { update_add_htlc_opt: None },
4 => {
let reason = match <u8 as Readable>::read(reader)? {
0 => InboundHTLCRemovalReason::FailRelay(msgs::OnionErrorPacket {
@@ -15301,6 +15318,7 @@ where
let mut pending_outbound_held_htlc_flags_opt: Option<Vec<Option<()>>> = None;
let mut holding_cell_held_htlc_flags_opt: Option<Vec<Option<()>>> = None;
+ let mut inbound_committed_update_adds_opt: Option<Vec<Option<msgs::UpdateAddHTLC>>> = None;
read_tlv_fields!(reader, {
(0, announcement_sigs, option),
@@ -15350,6 +15368,7 @@ where
(69, holding_cell_held_htlc_flags_opt, optional_vec), // Added in 0.2
(71, holder_commitment_point_previous_revoked_opt, option), // Added in 0.3
(73, holder_commitment_point_last_revoked_opt, option), // Added in 0.3
+ (75, inbound_committed_update_adds_opt, optional_vec),
});
let holder_signer = signer_provider.derive_channel_signer(channel_keys_id);
@@ -15473,6 +15492,17 @@ where
return Err(DecodeError::InvalidValue);
}
}
+ if let Some(update_adds) = inbound_committed_update_adds_opt {
+ let mut iter = update_adds.into_iter();
+ for htlc in pending_inbound_htlcs.iter_mut() {
+ if let InboundHTLCState::Committed { ref mut update_add_htlc_opt } = htlc.state {
+ *update_add_htlc_opt = iter.next().ok_or(DecodeError::InvalidValue)?;
+ }
+ }
+ if iter.next().is_some() {
+ return Err(DecodeError::InvalidValue);
+ }
+ }
if let Some(attribution_data_list) = removed_htlc_attribution_data {
let mut removed_htlcs = pending_inbound_htlcs.iter_mut().filter_map(|status| {
@@ -16057,7 +16087,7 @@ mod tests {
amount_msat: htlc_amount_msat,
payment_hash: PaymentHash(Sha256::hash(&[42; 32]).to_byte_array()),
cltv_expiry: 300000000,
- state: InboundHTLCState::Committed,
+ state: InboundHTLCState::Committed { update_add_htlc_opt: None },
});
node_a_chan.context.pending_outbound_htlcs.push(OutboundHTLCOutput {
@@ -16903,7 +16933,7 @@ mod tests {
amount_msat: 1000000,
cltv_expiry: 500,
payment_hash: PaymentHash::from(payment_preimage_0),
- state: InboundHTLCState::Committed,
+ state: InboundHTLCState::Committed { update_add_htlc_opt: None },
});
let payment_preimage_1 =
@@ -16913,7 +16943,7 @@ mod tests {
amount_msat: 2000000,
cltv_expiry: 501,
payment_hash: PaymentHash::from(payment_preimage_1),
- state: InboundHTLCState::Committed,
+ state: InboundHTLCState::Committed { update_add_htlc_opt: None },
});
let payment_preimage_2 =
@@ -16953,7 +16983,7 @@ mod tests {
amount_msat: 4000000,
cltv_expiry: 504,
payment_hash: PaymentHash::from(payment_preimage_4),
- state: InboundHTLCState::Committed,
+ state: InboundHTLCState::Committed { update_add_htlc_opt: None },
});
// commitment tx with all five HTLCs untrimmed (minimum feerate)
@@ -17342,7 +17372,7 @@ mod tests {
amount_msat: 2000000,
cltv_expiry: 501,
payment_hash: PaymentHash::from(payment_preimage_1),
- state: InboundHTLCState::Committed,
+ state: InboundHTLCState::Committed { update_add_htlc_opt: None },
});
chan.context.pending_outbound_htlcs.clear();
@@ -17593,7 +17623,7 @@ mod tests {
amount_msat: 5000000,
cltv_expiry: 920150,
payment_hash: PaymentHash::from(htlc_in_preimage),
- state: InboundHTLCState::Committed,
+ state: InboundHTLCState::Committed { update_add_htlc_opt: None },
}));
chan.context.pending_outbound_htlcs.extend(
@@ -17656,7 +17686,7 @@ mod tests {
amount_msat,
cltv_expiry: 920150,
payment_hash: PaymentHash::from(htlc_in_preimage),
- state: InboundHTLCState::Committed,
+ state: InboundHTLCState::Committed { update_add_htlc_opt: None },
},
));
@@ -17722,7 +17752,7 @@ mod tests {
amount_msat: 100000,
cltv_expiry: 920125,
payment_hash: htlc_0_in_hash,
- state: InboundHTLCState::Committed,
+ state: InboundHTLCState::Committed { update_add_htlc_opt: None },
});
let htlc_1_in_preimage =
@@ -17740,7 +17770,7 @@ mod tests {
amount_msat: 49900000,
cltv_expiry: 920125,
payment_hash: htlc_1_in_hash,
- state: InboundHTLCState::Committed,
+ state: InboundHTLCState::Committed { update_add_htlc_opt: None },
});
chan.context.pending_outbound_htlcs.extend(
@@ -17792,7 +17822,7 @@ mod tests {
amount_msat: 30000,
payment_hash,
cltv_expiry: 920125,
- state: InboundHTLCState::Committed,
+ state: InboundHTLCState::Committed { update_add_htlc_opt: None },
},
));
@@ -17833,7 +17863,7 @@ mod tests {
amount_msat: 29525,
payment_hash,
cltv_expiry: 920125,
- state: InboundHTLCState::Committed,
+ state: InboundHTLCState::Committed { update_add_htlc_opt: None },
},
));
@@ -17870,7 +17900,7 @@ mod tests {
amount_msat: 29525,
payment_hash,
cltv_expiry: 920125,
- state: InboundHTLCState::Committed,
+ state: InboundHTLCState::Committed { update_add_htlc_opt: None },
},
));
@@ -17907,7 +17937,7 @@ mod tests {
amount_msat: 29753,
payment_hash,
cltv_expiry: 920125,
- state: InboundHTLCState::Committed,
+ state: InboundHTLCState::Committed { update_add_htlc_opt: None },
},
));
@@ -17959,7 +17989,7 @@ mod tests {
amount_msat,
cltv_expiry,
payment_hash,
- state: InboundHTLCState::Committed,
+ state: InboundHTLCState::Committed { update_add_htlc_opt: None },
}),
);
Why this scored 31/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.