Persist hold_htlc flags in Channel
What changed, and why it matters
This commit adds a new 'hold_htlc' flag to Lightning channel data structures and makes sure it gets saved to and loaded from disk. The flag is not yet used to actually change any network behavior; it is only groundwork for a future feature that lets an often-offline sender ask the next node to hold an HTLC until a release message arrives. Because the flag is always set to None and never read for protocol decisions, it does not introduce a currently exploitable vulnerability.
No immediate security action required. Treat as normal feature groundwork. When the follow-up commits begin setting hold_htlc to Some(()), review the protocol logic that releases held HTLCs for correctness, DoS resistance, and timeout handling.
Security signals we found
New serialization field added with TLV optional vectors (tags 67 and 69)
Deserialization validates vector length matches HTLC count, returning DecodeError::InvalidValue on mismatch
Feature flag is never set to a non-None value in production code paths
No protocol logic currently branches on hold_htlc value
Commit message explicitly states groundwork only: 'We don't yet ever set this flag'
Evidence from the diff
The patch extends OutboundHTLCOutput and HTLCUpdateAwaitingACK::AddHTLC with an Option<()> field named hold_htlc, then threads it through channel serialization (write_tlv_fields/read_tlv_fields using optional_vec tags 67 and 69) and deserialization validation. All construction sites initialize it to None, and the only pattern match in send_held_htlc_update ignores the value with hold_htlc: _. The change is purely additive storage plumbing for BOLTs PR 989’s upcoming ‘often-offline sender’ feature.
Changed components
lightning/src/ln/channel.rsOutboundHTLCOutput structHTLCUpdateAwaitingACK::AddHTLC variantChannel serialization/deserialization (TLV fields 67 and 69)Inspect captured patch +49 / −0
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index e7ae35a..17bd70d 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -443,6 +443,7 @@ struct OutboundHTLCOutput {
blinding_point: Option<PublicKey>,
skimmed_fee_msat: Option<u64>,
send_timestamp: Option<Duration>,
+ hold_htlc: Option<()>,
}
impl OutboundHTLCOutput {
@@ -477,6 +478,7 @@ enum HTLCUpdateAwaitingACK {
// The extra fee we're skimming off the top of this HTLC.
skimmed_fee_msat: Option<u64>,
blinding_point: Option<PublicKey>,
+ hold_htlc: Option<()>,
},
ClaimHTLC {
payment_preimage: PaymentPreimage,
@@ -8046,6 +8048,7 @@ where
ref onion_routing_packet,
skimmed_fee_msat,
blinding_point,
+ hold_htlc: _,
..
} => {
match self.send_htlc(
@@ -12131,6 +12134,7 @@ where
onion_routing_packet,
skimmed_fee_msat,
blinding_point,
+ hold_htlc: None,
});
return Ok(false);
}
@@ -12152,6 +12156,7 @@ where
blinding_point,
skimmed_fee_msat,
send_timestamp,
+ hold_htlc: None,
});
self.context.next_holder_htlc_id += 1;
@@ -13990,6 +13995,7 @@ where
let mut fulfill_attribution_data = vec![];
let mut pending_outbound_skimmed_fees: Vec<Option<u64>> = Vec::new();
let mut pending_outbound_blinding_points: Vec<Option<PublicKey>> = Vec::new();
+ let mut pending_outbound_held_htlc_flags: Vec<Option<()>> = Vec::new();
(self.context.pending_outbound_htlcs.len() as u64).write(writer)?;
for htlc in self.context.pending_outbound_htlcs.iter() {
@@ -14032,6 +14038,7 @@ where
}
pending_outbound_skimmed_fees.push(htlc.skimmed_fee_msat);
pending_outbound_blinding_points.push(htlc.blinding_point);
+ pending_outbound_held_htlc_flags.push(htlc.hold_htlc);
}
let holding_cell_htlc_update_count = self.context.holding_cell_htlc_updates.len();
@@ -14041,6 +14048,8 @@ where
Vec::with_capacity(holding_cell_htlc_update_count);
let mut holding_cell_attribution_data: Vec<Option<&AttributionData>> =
Vec::with_capacity(holding_cell_htlc_update_count);
+ let mut holding_cell_held_htlc_flags: Vec<Option<()>> =
+ Vec::with_capacity(holding_cell_htlc_update_count);
// Vec of (htlc_id, failure_code, sha256_of_onion)
let mut malformed_htlcs: Vec<(u64, u16, [u8; 32])> = Vec::new();
(holding_cell_htlc_update_count as u64).write(writer)?;
@@ -14054,6 +14063,7 @@ where
ref onion_routing_packet,
blinding_point,
skimmed_fee_msat,
+ hold_htlc,
} => {
0u8.write(writer)?;
amount_msat.write(writer)?;
@@ -14064,6 +14074,7 @@ where
holding_cell_skimmed_fees.push(skimmed_fee_msat);
holding_cell_blinding_points.push(blinding_point);
+ holding_cell_held_htlc_flags.push(hold_htlc);
},
&HTLCUpdateAwaitingACK::ClaimHTLC {
ref payment_preimage,
@@ -14311,6 +14322,8 @@ where
(63, holder_commitment_point_current, option), // Added in 0.2
(64, self.pending_splice, option), // Added in 0.2
(65, self.quiescent_action, option), // Added in 0.2
+ (67, pending_outbound_held_htlc_flags, optional_vec), // Added in 0.2
+ (69, holding_cell_held_htlc_flags, optional_vec), // Added in 0.2
});
Ok(())
@@ -14459,6 +14472,7 @@ where
skimmed_fee_msat: None,
blinding_point: None,
send_timestamp: None,
+ hold_htlc: None,
});
}
@@ -14477,6 +14491,7 @@ where
onion_routing_packet: Readable::read(reader)?,
skimmed_fee_msat: None,
blinding_point: None,
+ hold_htlc: None,
},
1 => HTLCUpdateAwaitingACK::ClaimHTLC {
payment_preimage: Readable::read(reader)?,
@@ -14674,6 +14689,9 @@ where
let mut pending_splice: Option<PendingFunding> = None;
let mut quiescent_action = None;
+ let mut pending_outbound_held_htlc_flags_opt: Option<Vec<Option<()>>> = None;
+ let mut holding_cell_held_htlc_flags_opt: Option<Vec<Option<()>>> = None;
+
read_tlv_fields!(reader, {
(0, announcement_sigs, option),
(1, minimum_depth, option),
@@ -14718,6 +14736,8 @@ where
(63, holder_commitment_point_current_opt, option), // Added in 0.2
(64, pending_splice, option), // Added in 0.2
(65, quiescent_action, upgradable_option), // Added in 0.2
+ (67, pending_outbound_held_htlc_flags_opt, optional_vec), // Added in 0.2
+ (69, holding_cell_held_htlc_flags_opt, optional_vec), // Added in 0.2
});
let holder_signer = signer_provider.derive_channel_signer(channel_keys_id);
@@ -14819,6 +14839,28 @@ where
return Err(DecodeError::InvalidValue);
}
}
+ if let Some(held_htlcs) = pending_outbound_held_htlc_flags_opt {
+ let mut iter = held_htlcs.into_iter();
+ for htlc in pending_outbound_htlcs.iter_mut() {
+ htlc.hold_htlc = iter.next().ok_or(DecodeError::InvalidValue)?;
+ }
+ // We expect all held HTLC flags to be consumed above
+ if iter.next().is_some() {
+ return Err(DecodeError::InvalidValue);
+ }
+ }
+ if let Some(held_htlcs) = holding_cell_held_htlc_flags_opt {
+ let mut iter = held_htlcs.into_iter();
+ for htlc in holding_cell_htlc_updates.iter_mut() {
+ if let HTLCUpdateAwaitingACK::AddHTLC { ref mut hold_htlc, .. } = htlc {
+ *hold_htlc = iter.next().ok_or(DecodeError::InvalidValue)?;
+ }
+ }
+ // We expect all held HTLC flags to be consumed above
+ 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| {
@@ -15391,6 +15433,7 @@ mod tests {
skimmed_fee_msat: None,
blinding_point: None,
send_timestamp: None,
+ hold_htlc: None,
});
// Make sure when Node A calculates their local commitment transaction, none of the HTLCs pass
@@ -15845,6 +15888,7 @@ mod tests {
skimmed_fee_msat: None,
blinding_point: None,
send_timestamp: None,
+ hold_htlc: None,
};
let mut pending_outbound_htlcs = vec![dummy_outbound_output.clone(); 10];
for (idx, htlc) in pending_outbound_htlcs.iter_mut().enumerate() {
@@ -15870,6 +15914,7 @@ mod tests {
},
skimmed_fee_msat: None,
blinding_point: None,
+ hold_htlc: None,
};
let dummy_holding_cell_claim_htlc = |attribution_data| HTLCUpdateAwaitingACK::ClaimHTLC {
payment_preimage: PaymentPreimage([42; 32]),
@@ -16193,6 +16238,7 @@ mod tests {
skimmed_fee_msat: None,
blinding_point: None,
send_timestamp: None,
+ hold_htlc: None,
};
out.payment_hash.0 = Sha256::hash(&<Vec<u8>>::from_hex("0202020202020202020202020202020202020202020202020202020202020202").unwrap()).to_byte_array();
out
@@ -16208,6 +16254,7 @@ mod tests {
skimmed_fee_msat: None,
blinding_point: None,
send_timestamp: None,
+ hold_htlc: None,
};
out.payment_hash.0 = Sha256::hash(&<Vec<u8>>::from_hex("0303030303030303030303030303030303030303030303030303030303030303").unwrap()).to_byte_array();
out
@@ -16621,6 +16668,7 @@ mod tests {
skimmed_fee_msat: None,
blinding_point: None,
send_timestamp: None,
+ hold_htlc: None,
};
out.payment_hash.0 = Sha256::hash(&<Vec<u8>>::from_hex("0505050505050505050505050505050505050505050505050505050505050505").unwrap()).to_byte_array();
out
@@ -16636,6 +16684,7 @@ mod tests {
skimmed_fee_msat: None,
blinding_point: None,
send_timestamp: None,
+ hold_htlc: None,
};
out.payment_hash.0 = Sha256::hash(&<Vec<u8>>::from_hex("0505050505050505050505050505050505050505050505050505050505050505").unwrap()).to_byte_array();
out
Why this scored 19/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.