diff --git a/tests/test_opening.py b/tests/test_opening.py index 4954c31b87b4..56edd5b390e3 100644 --- a/tests/test_opening.py +++ b/tests/test_opening.py @@ -3018,3 +3018,144 @@ 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]}" + ) + + +@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]}" + )