ln: add accountable signal to HTLCUpdateAwaitingACK::AddHTLC
What changed, and why it matters
This commit adds a new 'accountable' flag to forwarded Lightning payments that is preserved when an HTLC is queued in a channel's holding cell. The flag appears intended to track whether a downstream node can be held financially responsible if a forwarded payment fails after the upstream side has already been settled. The change itself is a data plumbing patch; it does not contain an obvious active vulnerability, but it is part of a larger accountability mechanism whose security implications depend on how the flag is consumed elsewhere.
Review the broader series of commits around this change to confirm where `accountable` is read and how it affects failure handling, blame attribution, or penalty transactions. Verify that deserialization of the optional `holding_cell_accountable` vector cannot be manipulated to create a mismatch between queued HTLCs and their accountability flags.
Security signals we found
New boolean accountability signal added to forwarded HTLC state machine
Flag is persisted in channel holding-cell serialization (TLV type 77)
Default value is `false` for sender-originated HTLCs
Forwarded HTLCs inherit `incoming_accountable` from upstream
No enforcement/penalty logic visible in this commit
Evidence from the diff
The patch threads a boolean accountable field through HTLCUpdateAwaitingACK::AddHTLC, Channel::queue_add_htlc, Channel::send_htlc, and Channel::send_htlc_and_commit, and serializes/deserializes it in the channel holding-cell TLV stream (type 77). For locally originated payments the flag defaults to false; for forwarded HTLCs the flag is copied from the incoming HTLC’s incoming_accountable field. The change is backward-compatible via an optional TLV vector. No logic in this diff acts on the flag beyond storing and forwarding it.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rsHTLCUpdateAwaitingACK::AddHTLCChannel holding cell serializationInspect captured patch +36 / −4
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 6a05f15..93b79a8 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -469,6 +469,7 @@ enum HTLCUpdateAwaitingACK {
skimmed_fee_msat: Option<u64>,
blinding_point: Option<PublicKey>,
hold_htlc: Option<()>,
+ accountable: bool,
},
ClaimHTLC {
payment_preimage: PaymentPreimage,
@@ -8405,7 +8406,7 @@ where
skimmed_fee_msat,
blinding_point,
hold_htlc,
- ..
+ accountable,
} => {
match self.send_htlc(
amount_msat,
@@ -8417,6 +8418,7 @@ where
skimmed_fee_msat,
blinding_point,
hold_htlc.is_some(),
+ accountable,
fee_estimator,
logger,
) {
@@ -12593,7 +12595,8 @@ where
pub fn queue_add_htlc<F: Deref, L: Deref>(
&mut self, amount_msat: u64, payment_hash: PaymentHash, cltv_expiry: u32,
source: HTLCSource, onion_routing_packet: msgs::OnionPacket, skimmed_fee_msat: Option<u64>,
- blinding_point: Option<PublicKey>, fee_estimator: &LowerBoundedFeeEstimator<F>, logger: &L,
+ blinding_point: Option<PublicKey>, accountable: bool,
+ fee_estimator: &LowerBoundedFeeEstimator<F>, logger: &L,
) -> Result<(), (LocalHTLCFailureReason, String)>
where
F::Target: FeeEstimator,
@@ -12610,6 +12613,7 @@ where
blinding_point,
// This method is only called for forwarded HTLCs, which are never held at the next hop
false,
+ accountable,
fee_estimator,
logger,
)
@@ -12641,7 +12645,7 @@ where
&mut self, amount_msat: u64, payment_hash: PaymentHash, cltv_expiry: u32,
source: HTLCSource, onion_routing_packet: msgs::OnionPacket, mut force_holding_cell: bool,
skimmed_fee_msat: Option<u64>, blinding_point: Option<PublicKey>, hold_htlc: bool,
- fee_estimator: &LowerBoundedFeeEstimator<F>, logger: &L,
+ accountable: bool, fee_estimator: &LowerBoundedFeeEstimator<F>, logger: &L,
) -> Result<bool, (LocalHTLCFailureReason, String)>
where
F::Target: FeeEstimator,
@@ -12723,6 +12727,7 @@ where
skimmed_fee_msat,
blinding_point,
hold_htlc: hold_htlc.then(|| ()),
+ accountable,
});
return Ok(false);
}
@@ -12994,7 +12999,8 @@ where
pub fn send_htlc_and_commit<F: Deref, L: Deref>(
&mut self, amount_msat: u64, payment_hash: PaymentHash, cltv_expiry: u32,
source: HTLCSource, onion_routing_packet: msgs::OnionPacket, skimmed_fee_msat: Option<u64>,
- hold_htlc: bool, fee_estimator: &LowerBoundedFeeEstimator<F>, logger: &L,
+ hold_htlc: bool, accountable: bool, fee_estimator: &LowerBoundedFeeEstimator<F>,
+ logger: &L,
) -> Result<Option<ChannelMonitorUpdate>, ChannelError>
where
F::Target: FeeEstimator,
@@ -13010,6 +13016,7 @@ where
skimmed_fee_msat,
None,
hold_htlc,
+ accountable,
fee_estimator,
logger,
);
@@ -14679,6 +14686,8 @@ where
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);
+ let mut holding_cell_accountable_flags: Vec<bool> =
+ 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)?;
@@ -14693,6 +14702,7 @@ where
blinding_point,
skimmed_fee_msat,
hold_htlc,
+ accountable,
} => {
0u8.write(writer)?;
amount_msat.write(writer)?;
@@ -14704,6 +14714,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);
+ holding_cell_accountable_flags.push(accountable);
},
&HTLCUpdateAwaitingACK::ClaimHTLC {
ref payment_preimage,
@@ -14965,6 +14976,7 @@ where
(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),
+ (77, holding_cell_accountable_flags, optional_vec), // Added in 0.3
});
Ok(())
@@ -15151,6 +15163,7 @@ where
skimmed_fee_msat: None,
blinding_point: None,
hold_htlc: None,
+ accountable: false,
},
1 => HTLCUpdateAwaitingACK::ClaimHTLC {
payment_preimage: Readable::read(reader)?,
@@ -15353,6 +15366,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;
+ let mut holding_cell_accountable: Option<Vec<bool>> = None;
read_tlv_fields!(reader, {
(0, announcement_sigs, option),
@@ -15403,6 +15417,7 @@ where
(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),
+ (77, holding_cell_accountable, optional_vec), // Added in 0.3
});
let holder_signer = signer_provider.derive_channel_signer(channel_keys_id);
@@ -15538,6 +15553,19 @@ where
}
}
+ if let Some(accountable_htlcs) = holding_cell_accountable {
+ let mut iter = accountable_htlcs.into_iter();
+ for htlc in holding_cell_htlc_updates.iter_mut() {
+ if let HTLCUpdateAwaitingACK::AddHTLC { ref mut accountable, .. } = htlc {
+ *accountable = iter.next().ok_or(DecodeError::InvalidValue)?;
+ }
+ }
+ // We expect all accountable HTLC signals 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| {
if let InboundHTLCState::LocalRemoved(reason) = &mut status.state {
@@ -16622,6 +16650,7 @@ mod tests {
skimmed_fee_msat: None,
blinding_point: None,
hold_htlc: None,
+ accountable: false,
};
let dummy_holding_cell_claim_htlc = |attribution_data| HTLCUpdateAwaitingACK::ClaimHTLC {
payment_preimage: PaymentPreimage([42; 32]),
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 6284ded..26255b9 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -5479,6 +5479,7 @@ where
onion_packet,
None,
hold_htlc_at_next_hop,
+ false, // Not accountable by default for sender.
&self.fee_estimator,
&&logger,
);
@@ -7587,6 +7588,7 @@ where
outgoing_cltv_value,
routing,
skimmed_fee_msat,
+ incoming_accountable,
..
},
..
@@ -7687,6 +7689,7 @@ where
onion_packet.clone(),
*skimmed_fee_msat,
next_blinding_point,
+ *incoming_accountable,
&self.fee_estimator,
&&logger,
) {
Why this scored 45/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.