Fix missing user_channel_id in PaymentForwarded
What changed, and why it matters
This commit fixes a bug where a forwarding Lightning node, after restarting mid-payment, could emit a 'PaymentForwarded' event that was missing the identifier (next_user_channel_id) of the next channel the payment went through. The fix ensures the identifier is correctly looked up from the still-open channel state during startup. It is a data-correctness bug, not a direct loss-of-funds vulnerability, but it could break downstream accounting or automation that relies on this field.
Review any downstream systems consuming PaymentForwarded events to ensure they handle the now-restored next_user_channel_id correctly; apply the patch to avoid missing channel identifiers after reloads.
Security signals we found
Missing event field after node restart mid-HTLC forward
State reconstruction from channel monitors on reload
Regression test added asserting field presence
Data-correctness issue in event reporting
Evidence from the diff
In ChannelManager::finish_reloading_from_monitors, when iterating channel monitors during startup, the code previously only checked whether the channel was still open in peer state and did not capture the user_channel_id. As a result, when reconstructing pending outbound HTLC forwards whose preimage was stored in the outbound monitor, the emitted PaymentForwarded event was constructed with next_user_channel_id = None even though the channel was still open and the identifier was available. The patch captures chan.context().get_user_id() when the channel is found in peer_state.channel_by_id and passes it through to the event construction. A regression test in chanmon_update_fail_tests.rs asserts next_user_channel_id.is_some() when claim_from_onchain_tx is false and the outbound channel is still open.
Changed components
lightning/src/ln/channelmanager.rslightning/src/ln/chanmon_update_fail_tests.rsPaymentForwarded event generationInspect captured patch +13 / −6
diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs
index 5a0c37b..e5f6b72 100644
--- a/lightning/src/ln/chanmon_update_fail_tests.rs
+++ b/lightning/src/ln/chanmon_update_fail_tests.rs
@@ -3938,7 +3938,12 @@ fn do_test_durable_preimages_on_closed_channel(
let evs = nodes[1].node.get_and_clear_pending_events();
assert_eq!(evs.len(), if close_chans_before_reload { 2 } else { 1 });
for ev in evs {
- if let Event::PaymentForwarded { .. } = ev {
+ if let Event::PaymentForwarded { claim_from_onchain_tx, next_user_channel_id, .. } = ev {
+ if !claim_from_onchain_tx {
+ // If the outbound channel is still open, the `next_user_channel_id` should be available.
+ // This was previously broken.
+ assert!(next_user_channel_id.is_some())
+ }
} else {
panic!();
}
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 897f10c..40342d7 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -18707,14 +18707,16 @@ impl<
}
}
for (channel_id, monitor) in args.channel_monitors.iter() {
- let mut is_channel_closed = true;
+ let (mut is_channel_closed, mut user_channel_id_opt) = (true, None);
let counterparty_node_id = monitor.get_counterparty_node_id();
if let Some(peer_state_mtx) = per_peer_state.get(&counterparty_node_id) {
let mut peer_state_lock = peer_state_mtx.lock().unwrap();
let peer_state = &mut *peer_state_lock;
- is_channel_closed = !peer_state.channel_by_id.contains_key(channel_id);
- if reconstruct_manager_from_monitors && !is_channel_closed {
- if let Some(chan) = peer_state.channel_by_id.get(channel_id) {
+ if let Some(chan) = peer_state.channel_by_id.get(channel_id) {
+ is_channel_closed = false;
+ user_channel_id_opt = Some(chan.context().get_user_id());
+
+ if reconstruct_manager_from_monitors {
if let Some(funded_chan) = chan.as_funded() {
for (payment_hash, prev_hop) in funded_chan.outbound_htlc_forwards()
{
@@ -19014,7 +19016,7 @@ impl<
Some((htlc_source, payment_preimage, htlc.amount_msat,
is_channel_closed, monitor.get_counterparty_node_id(),
- monitor.get_funding_txo(), monitor.channel_id(), None))
+ monitor.get_funding_txo(), monitor.channel_id(), user_channel_id_opt))
} else { None }
} else {
// If it was an outbound payment, we've handled it above - if a preimage
Why this scored 34/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.