From da5c1e4ddd8785b88ecf467aa31dfb8f1662e411 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Tue, 5 May 2026 10:08:16 -0700 Subject: [PATCH 1/2] tests: add xfail-strict reproducer for funder-side stuck CHANNELD_AWAITING_LOCKIN When a node is the funder of a channel whose funding tx never confirms (broadcast rejected at ATMP, evicted from mempool, or simply never broadcast), the channel record stays in CHANNELD_AWAITING_LOCKIN indefinitely. CLN already implements the BOLT 2 fundee-side forget rule (PR #1468, --max-funding-unconfirmed-blocks, default 2016) but has no equivalent on the funder side. The test asserts the desired post-fix behavior (state has moved beyond CHANNELD_AWAITING_LOCKIN) and is marked @pytest.mark.xfail(strict=True) so: - CI reports XFAIL today (acceptable; documents the open bug) - When the bug is fixed, the test reports XPASS, which strict=True promotes to a hard failure to alert the dev to remove the marker. Changelog-None --- tests/test_opening.py | 75 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/test_opening.py b/tests/test_opening.py index 4954c31b87b4..7c4e3de05e73 100644 --- a/tests/test_opening.py +++ b/tests/test_opening.py @@ -3018,3 +3018,78 @@ def test_zeroconf_withhold_htlc_failback(node_factory, bitcoind): # l1's channel to l2 is still normal — no force-close assert only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['state'] == 'CHANNELD_NORMAL' + + +@pytest.mark.xfail( + strict=True, + reason="Bug: funder-side channel stuck in CHANNELD_AWAITING_LOCKIN if funding never confirms" +) +def test_funder_stuck_no_funding_confirm(node_factory, bitcoind): + """Funder-side channel record is never cleaned up if funding never confirms. + + BOLT 2 mandates a 2016-block forget rule for fundees and CLN + implements it (PR #1468, --max-funding-unconfirmed-blocks). But CLN + has no equivalent on the funder side: when the funding tx is + rejected at broadcast (e.g. fee too low) or evicted from mempool, + the channel record persists in CHANNELD_AWAITING_LOCKIN + indefinitely with no auto-cleanup mechanism. Even after the + would-be funding inputs are spent in other transactions (making + the funding tx permanently unconfirmable), the channel record + remains. + + This test demonstrates the stuck state. It is marked xfail-strict + because no fix yet exists; once fixed, the marker should be + removed. + """ + # Lower the unconfirmed-funding threshold on the funder so we + # don't have to mine 2016 blocks to make the point. This dev + # knob is the same one CLN's existing fundee-side test + # (test_zeroconf_forget) uses to control --max-funding-unconfirmed-blocks. + # On the funder side, no code path consults it — that's the bug. + THRESHOLD = 10 + l1, l2 = node_factory.line_graph( + 2, + fundchannel=False, + opts={'dev-max-funding-unconfirmed-blocks': THRESHOLD}, + ) + l1.fundwallet(10**7) + + # Censor sendrawtransaction so the funding tx never reaches + # bitcoind's mempool. lightningd will think the broadcast + # succeeded; bitcoind never sees the tx. Same trick as + # test_zeroconf_forget. + def censor(tx): + return {'id': tx['id'], 'result': {}} + l1.daemon.rpcproxy.mock_rpc('sendrawtransaction', censor) + + # Open the channel. Broadcast appears to succeed (mock) but the + # tx never lands. + l1.rpc.fundchannel(l2.info['id'], 10**6) + + # Both sides reach CHANNELD_AWAITING_LOCKIN. + wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['state'] + == 'CHANNELD_AWAITING_LOCKIN') + wait_for(lambda: only_one(l2.rpc.listpeerchannels()['channels'])['state'] + == 'CHANNELD_AWAITING_LOCKIN') + + # Advance past THRESHOLD blocks. The funder side has no forget + # code path that consults THRESHOLD, so the channel record is + # expected to remain in CHANNELD_AWAITING_LOCKIN even though we + # set the knob low. + bitcoind.generate_block(THRESHOLD + 5) + sync_blockheight(bitcoind, [l1, l2]) + + # Sanity: funding never confirmed. + assert only_one(l1.rpc.listpeerchannels()['channels']).get('short_channel_id') is None + + # Expected behavior under fix: funder's channel record has been + # cleaned up (forgotten, transitioned to a new "abandoned" + # terminal state, or some other resolved disposition). Any + # forward progress is enough; we do not prescribe a specific + # cleanup shape. + chans_l1 = l1.rpc.listpeerchannels()['channels'] + assert all(c['state'] != 'CHANNELD_AWAITING_LOCKIN' for c in chans_l1), ( + f"l1 (funder) still has channel in CHANNELD_AWAITING_LOCKIN " + f"after {THRESHOLD + 5} blocks (THRESHOLD={THRESHOLD}): " + f"{[c['state'] for c in chans_l1]}" + ) From 19a7976f9fba83a4f5a5fd517910bcd7e44018dc Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Tue, 5 May 2026 12:09:36 -0700 Subject: [PATCH 2/2] tests: add xfail-strict reproducer for funder-side stuck AWAITING_UNILATERAL Same root cause as the previous test_funder_stuck_no_funding_confirm (funding tx unbroadcastable/unconfirmable, no funder-side cleanup). This variant covers the second symptom: when the operator (or an automation like CLBOSS's spenderp) issues `close` on the AWAITING_LOCKIN channel, CLN transitions to AWAITING_UNILATERAL and tries to broadcast a commitment tx that spends the (non-existent) funding output. That commit tx can never confirm either, so the channel record now sits stuck in AWAITING_UNILATERAL indefinitely. Stops l2 before close to force unilateral and avoid mutual close racing in. Marked xfail-strict so the bug is documented without breaking CI. Changelog-None --- tests/test_opening.py | 66 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/test_opening.py b/tests/test_opening.py index 7c4e3de05e73..56edd5b390e3 100644 --- a/tests/test_opening.py +++ b/tests/test_opening.py @@ -3093,3 +3093,69 @@ def censor(tx): f"after {THRESHOLD + 5} blocks (THRESHOLD={THRESHOLD}): " f"{[c['state'] for c in chans_l1]}" ) + + +@pytest.mark.xfail( + strict=True, + reason="Bug: funder-side channel stuck in AWAITING_UNILATERAL if closed before funding confirms" +) +def test_funder_stuck_close_before_funding_confirm(node_factory, bitcoind): + """Funder-side channel stuck in AWAITING_UNILATERAL after close + if funding never confirmed. + + Same root cause as test_funder_stuck_no_funding_confirm: the + funding tx is unbroadcastable/unconfirmable and CLN has no + funder-side cleanup. This variant covers what happens when the + operator (or an automation like CLBOSS's spenderp) issues `close` + on the AWAITING_LOCKIN channel: CLN transitions to + AWAITING_UNILATERAL and tries to broadcast a commitment tx that + spends the (non-existent) funding output. That commit tx can + never confirm either, so the channel record now sits stuck in + AWAITING_UNILATERAL indefinitely. + + Marked xfail-strict because no fix yet exists; once fixed, the + marker should be removed. + """ + THRESHOLD = 10 + l1, l2 = node_factory.line_graph( + 2, + fundchannel=False, + opts={'dev-max-funding-unconfirmed-blocks': THRESHOLD}, + ) + l1.fundwallet(10**7) + + def censor(tx): + return {'id': tx['id'], 'result': {}} + l1.daemon.rpcproxy.mock_rpc('sendrawtransaction', censor) + + l1.rpc.fundchannel(l2.info['id'], 10**6) + wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['state'] + == 'CHANNELD_AWAITING_LOCKIN') + wait_for(lambda: only_one(l2.rpc.listpeerchannels()['channels'])['state'] + == 'CHANNELD_AWAITING_LOCKIN') + + # Force unilateral close. Stopping l2 ensures mutual close cannot + # race in and land us in CLOSINGD_COMPLETE instead. + l2.stop() + l1.rpc.close(l2.info['id'], unilateraltimeout=1) + + # Funder transitions to AWAITING_UNILATERAL with a commit tx whose + # input is the never-existing funding output. The commit tx is + # also censored by the mock; even without the mock it would be + # rejected by bitcoind for spending a non-existent output. + wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['state'] + == 'AWAITING_UNILATERAL') + + bitcoind.generate_block(THRESHOLD + 5) + sync_blockheight(bitcoind, [l1]) + + # Expected behavior under fix: funder's channel record has been + # cleaned up (forgotten, transitioned to a new "abandoned" terminal + # state, or some other resolved disposition). Any forward progress + # is enough; we do not prescribe a specific cleanup shape. + chans_l1 = l1.rpc.listpeerchannels()['channels'] + assert all(c['state'] != 'AWAITING_UNILATERAL' for c in chans_l1), ( + f"l1 (funder) still has channel in AWAITING_UNILATERAL " + f"after {THRESHOLD + 5} blocks (THRESHOLD={THRESHOLD}): " + f"{[c['state'] for c in chans_l1]}" + )