If a non-tip inflight is the one that gets mined, and the peer reconnects while we are still catching up on blocks, we lock the channel in with the tip inflight -- which was never mined.
-
The block containing the mined inflight is processed. dual_funding_found fires inline and sets channel->scid, but update_channel_from_inflight runs later, from the blockdepth watches in updates_complete, only once we have caught up on all queued blocks. Until then channel->funding still points at the tip inflight.
-
The peer reconnects inside that window. peer_restart_dualopend reinits dualopend with channel_current_inflight() -- the tip -- plus channel->scid != NULL, which dualopend takes as funding-is-confirmed. It sends channel_ready.
-
The peer, who identified the mined inflight correctly, sends channel_ready back. handle_channel_locked promotes to CHANNELD_NORMAL with the tip inflight's funding and clears the inflight list, discarding the pending correction.
-
We are now in CHANNELD_NORMAL with a funding_txid that was never mined, an scid that points at the other tx's outpoint, and a last_tx that spends a nonexistent outpoint -- nothing to enforce onchain -- while the peer locked in the mined inflight, so the two sides also disagree with each other.
The window is one block-fetch wide, so any reconnect during catch-up can hit it. All it takes to set it up is an RBF replacement that failed to propagate, leaving a non-tip inflight as the one the chain picks.
We can reproduce this deterministically: stall the node's getblockhash for the block after the funding block (holding the catch-up window open) and reconnect. Fails the same way every run, in about 15 seconds. PR with the regression test and a proposed fix to follow.
Addendum: how it surfaced. Master run for 8c42be3, Test CLN dual-fund Full Integration:
FAILED tests/test_opening.py::test_rbf_non_last_mined - AssertionError: assert 'd6ef2bda0490820a06461cd63d6bb75d4ba11fe2e58b9e61935d7ea904d2b3b5' == '597180eccc316cec5b817794e8f29d848b4b8364762a8e1e567f3bc7bc1e8f8a'
https://github.com/ElementsProject/lightning/actions/runs/30835065159 (logs expire, key lines below). 597180 was the mined inflight; d6ef2b was the tip, never in the mempool. l1:
17:56:23.995 lightningd: Adding block 103: 4ab240...
17:56:23.997 wallet: Owning output 0 17667576sat (p2tr) txid 597180ec... CONFIRMED
17:56:24.442 chan#1: Peer has reconnected, state DUALOPEND_AWAITING_LOCKIN: connecting subd
17:56:24.515 chan#1: State changed from DUALOPEND_AWAITING_LOCKIN to CHANNELD_NORMAL
17:56:24.602 chan#1: Funding tx d6ef2bda... depth 6 of 1
17:56:24.617 channeld-chan#1: handle_funding_depth: Setting short_channel_ids[LOCAL] to 103x1x1
(103x1x1 is 597180's funding outpoint -- its funding output is vout 1; d6ef2b's is vout 0.)
Part of #9222.
If a non-tip inflight is the one that gets mined, and the peer reconnects while we are still catching up on blocks, we lock the channel in with the tip inflight -- which was never mined.
The block containing the mined inflight is processed. dual_funding_found fires inline and sets channel->scid, but update_channel_from_inflight runs later, from the blockdepth watches in updates_complete, only once we have caught up on all queued blocks. Until then channel->funding still points at the tip inflight.
The peer reconnects inside that window. peer_restart_dualopend reinits dualopend with channel_current_inflight() -- the tip -- plus channel->scid != NULL, which dualopend takes as funding-is-confirmed. It sends channel_ready.
The peer, who identified the mined inflight correctly, sends channel_ready back. handle_channel_locked promotes to CHANNELD_NORMAL with the tip inflight's funding and clears the inflight list, discarding the pending correction.
We are now in CHANNELD_NORMAL with a funding_txid that was never mined, an scid that points at the other tx's outpoint, and a last_tx that spends a nonexistent outpoint -- nothing to enforce onchain -- while the peer locked in the mined inflight, so the two sides also disagree with each other.
The window is one block-fetch wide, so any reconnect during catch-up can hit it. All it takes to set it up is an RBF replacement that failed to propagate, leaving a non-tip inflight as the one the chain picks.
We can reproduce this deterministically: stall the node's getblockhash for the block after the funding block (holding the catch-up window open) and reconnect. Fails the same way every run, in about 15 seconds. PR with the regression test and a proposed fix to follow.
Addendum: how it surfaced. Master run for 8c42be3, Test CLN dual-fund Full Integration:
FAILED tests/test_opening.py::test_rbf_non_last_mined - AssertionError: assert 'd6ef2bda0490820a06461cd63d6bb75d4ba11fe2e58b9e61935d7ea904d2b3b5' == '597180eccc316cec5b817794e8f29d848b4b8364762a8e1e567f3bc7bc1e8f8a'
https://github.com/ElementsProject/lightning/actions/runs/30835065159 (logs expire, key lines below). 597180 was the mined inflight; d6ef2b was the tip, never in the mempool. l1:
17:56:23.995 lightningd: Adding block 103: 4ab240...
17:56:23.997 wallet: Owning output 0 17667576sat (p2tr) txid 597180ec... CONFIRMED
17:56:24.442 chan#1: Peer has reconnected, state DUALOPEND_AWAITING_LOCKIN: connecting subd
17:56:24.515 chan#1: State changed from DUALOPEND_AWAITING_LOCKIN to CHANNELD_NORMAL
17:56:24.602 chan#1: Funding tx d6ef2bda... depth 6 of 1
17:56:24.617 channeld-chan#1: handle_funding_depth: Setting short_channel_ids[LOCAL] to 103x1x1
(103x1x1 is 597180's funding outpoint -- its funding output is vout 1; d6ef2b's is vout 0.)
Part of #9222.