Skip to content

Commit ffdb611

Browse files
committed
Narrow TOCTOU window in execute_htlc_actions
forward_intercepted_htlc consumes the InterceptId on Ok, but Ok only means the HTLC was queued internally — the actual send_htlc can still fail if the peer disconnects before the commitment update is exchanged. This created two problems: 1. The peer could disconnect between computing actions (usability check passes) and executing the forward. The forward would succeed at the LDK API layer but silently fail at send_htlc, leaving the HTLC in limbo with a consumed InterceptId. 2. Removing the HTLC from the store immediately after Ok meant the retry timer could never re-attempt it. If the internal send_htlc failed, the HTLC was lost until a new HTLCIntercepted event arrived with a fresh InterceptId. Add a pre-forward peer liveness check to skip forwarding when the peer has already disconnected. On Ok, keep the HTLC in the store so the retry timer can detect and re-process it. On Err, remove it — the InterceptId is consumed or stale, so a new HTLCIntercepted event will arrive for a fresh retry. An alternative considered was making forward_intercepted_htlc itself check peer liveness, but that would require plumbing connection state into the channel manager's intercept path. The pre-check here is simpler and sufficient — the residual race window is sub-millisecond.
1 parent c14b445 commit ffdb611

1 file changed

Lines changed: 38 additions & 44 deletions

File tree

lightning-liquidity/src/lsps4/service.rs

Lines changed: 38 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -726,29 +726,17 @@ where
726726
) {
727727
// Execute forwards
728728
for forward_action in actions.forwards {
729-
// Log channel state RIGHT BEFORE forwarding - key diagnostic
730-
let channels = self
731-
.channel_manager
732-
.get_cm()
733-
.list_channels_with_counterparty(&their_node_id);
734-
let target_channel =
735-
channels.iter().find(|ch| ch.channel_id == forward_action.channel_id);
736-
if let Some(ch) = target_channel {
729+
// Re-check peer liveness right before forwarding to narrow the
730+
// TOCTOU window between the usability check and the actual forward.
731+
if !self.is_peer_connected(&their_node_id) {
737732
log_info!(
738733
self.logger,
739-
"[LSPS4] execute_htlc_actions: PRE-FORWARD channel {} - is_usable: {}, is_channel_ready: {}, outbound_capacity: {}msat",
740-
ch.channel_id,
741-
ch.is_usable,
742-
ch.is_channel_ready,
743-
ch.outbound_capacity_msat
744-
);
745-
} else {
746-
log_error!(
747-
self.logger,
748-
"[LSPS4] execute_htlc_actions: TARGET CHANNEL {} NOT FOUND for peer {} before forward!",
749-
forward_action.channel_id,
750-
their_node_id
734+
"[LSPS4] execute_htlc_actions: peer {} disconnected before forward, skipping HTLC {:?} (will retry on next timer tick). payment_hash: {}",
735+
their_node_id,
736+
forward_action.htlc.id(),
737+
forward_action.htlc.payment_hash()
751738
);
739+
continue;
752740
}
753741

754742
log_info!(
@@ -769,40 +757,46 @@ where
769757
forward_action.amount_to_forward_msat,
770758
) {
771759
Ok(()) => {
760+
// Ok means the InterceptId was consumed and the HTLC queued for
761+
// forwarding. However, the internal send_htlc can still fail if the
762+
// peer disconnects between now and the commitment update. Keep the
763+
// HTLC in the store; the next timer tick will attempt to re-forward
764+
// the (now consumed) InterceptId, get Err, and clean up. If the
765+
// internal forward truly failed, a new HTLCIntercepted event will
766+
// arrive with a fresh InterceptId and be processed normally.
772767
log_info!(
773768
self.logger,
774-
"[LSPS4] execute_htlc_actions: forward_intercepted_htlc returned Ok for HTLC {:?} (took {}ms). NOTE: Ok does NOT guarantee delivery - channel may still be reestablishing.",
769+
"[LSPS4] execute_htlc_actions: forward_intercepted_htlc returned Ok for HTLC {:?} (took {}ms), keeping in store until confirmed. payment_hash: {}",
775770
forward_action.htlc.id(),
776-
fwd_start.elapsed().as_millis()
771+
fwd_start.elapsed().as_millis(),
772+
forward_action.htlc.payment_hash()
777773
);
778774
},
779775
Err(ref e) => {
780-
log_error!(
781-
self.logger,
782-
"[LSPS4] execute_htlc_actions: forward_intercepted_htlc FAILED for HTLC {:?} - error: {:?} (took {}ms)",
783-
forward_action.htlc.id(),
784-
e,
785-
fwd_start.elapsed().as_millis()
786-
);
787-
},
788-
}
789-
790-
// Remove from store - log whether it was actually present
791-
match self.htlc_store.remove(&forward_action.htlc.id()) {
792-
Ok(()) => {
793-
log_info!(
794-
self.logger,
795-
"[LSPS4] execute_htlc_actions: removed HTLC {:?} from store after forward",
796-
forward_action.htlc.id()
797-
);
798-
},
799-
Err(e) => {
776+
// InterceptId was already consumed or not found. Clean up the store.
800777
log_info!(
801778
self.logger,
802-
"[LSPS4] execute_htlc_actions: HTLC {:?} was NOT in store (expected if from htlc_intercepted connected path): {}",
779+
"[LSPS4] execute_htlc_actions: forward returned Err for HTLC {:?}: {:?} (took {}ms), removing from store. payment_hash: {}",
803780
forward_action.htlc.id(),
804-
e
781+
e,
782+
fwd_start.elapsed().as_millis(),
783+
forward_action.htlc.payment_hash()
805784
);
785+
match self.htlc_store.remove(&forward_action.htlc.id()) {
786+
Ok(()) => log_info!(
787+
self.logger,
788+
"[LSPS4] execute_htlc_actions: removed HTLC {:?} from store. payment_hash: {}",
789+
forward_action.htlc.id(),
790+
forward_action.htlc.payment_hash()
791+
),
792+
Err(e) => log_info!(
793+
self.logger,
794+
"[LSPS4] execute_htlc_actions: HTLC {:?} not in store (already removed): {}. payment_hash: {}",
795+
forward_action.htlc.id(),
796+
e,
797+
forward_action.htlc.payment_hash()
798+
),
799+
}
806800
},
807801
}
808802
}

0 commit comments

Comments
 (0)