Skip to content

Commit 9dd230b

Browse files
committed
tests: add xfail-strict reproducer for stuck CLOSINGD_COMPLETE without funding
When mutual close negotiation completes on a channel whose funding tx never confirms, the channel state is stuck. The signed close tx is permanently invalid (its input is a 2-of-2 funding output that does not exist on chain), so the state machine has no path from CLOSINGD_COMPLETE to FUNDING_SPEND_SEEN / ONCHAIN. The channel sits indefinitely with no cleanup. The test asserts the desired post-fix behavior (state has moved beyond CLOSINGD_COMPLETE) and is marked @pytest.mark.xfail(strict=True). Changelog-None
1 parent 0c63d01 commit 9dd230b

1 file changed

Lines changed: 79 additions & 0 deletions

File tree

tests/test_closing.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3661,6 +3661,85 @@ def test_close_twice(node_factory, executor):
36613661
assert fut2.result(TIMEOUT)['type'] == 'mutual'
36623662

36633663

3664+
@pytest.mark.xfail(
3665+
strict=True,
3666+
reason="Bug: channel stuck in CLOSINGD_COMPLETE if funding never confirms"
3667+
)
3668+
def test_closingd_complete_stuck_no_funding(node_factory, bitcoind):
3669+
"""Mutual close pre-lockin + funding never confirms → permanent CLOSINGD_COMPLETE.
3670+
3671+
BOLT 2 explicitly permits sending `shutdown` before `channel_ready`
3672+
(i.e. before the funding tx has reached `minimum_depth`). Both sides
3673+
happily complete the mutual close negotiation and persist a
3674+
fully-signed close tx. If the funding tx then never confirms, the
3675+
close tx is permanently invalid — its only input is a 2-of-2 funding
3676+
output that does not exist on chain. The state machine has no path
3677+
from CLOSINGD_COMPLETE to FUNDING_SPEND_SEEN / ONCHAIN, and there is
3678+
no cleanup. The channel record sits in CLOSINGD_COMPLETE
3679+
indefinitely.
3680+
3681+
This test demonstrates the stuck state. It is marked xfail-strict
3682+
because no fix yet exists; once fixed, the marker should be removed.
3683+
"""
3684+
l1, l2 = node_factory.line_graph(2, fundchannel=False)
3685+
3686+
# Fund l1's on-chain wallet
3687+
l1.fundwallet(10**7)
3688+
3689+
# Open the channel: funding tx is broadcast to bitcoind's mempool,
3690+
# but we do NOT mine it.
3691+
res = l1.rpc.fundchannel(l2.info['id'], 10**6)
3692+
funding_txid = res['txid']
3693+
3694+
# Both sides reach CHANNELD_AWAITING_LOCKIN
3695+
wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['state']
3696+
== 'CHANNELD_AWAITING_LOCKIN')
3697+
wait_for(lambda: only_one(l2.rpc.listpeerchannels()['channels'])['state']
3698+
== 'CHANNELD_AWAITING_LOCKIN')
3699+
3700+
# Confirm the funding tx is in the mempool but NOT yet in any block
3701+
assert funding_txid in bitcoind.rpc.getrawmempool()
3702+
3703+
# Push funding's effective fee far below any block-min-fee so future
3704+
# generated blocks do not include it. pyln-testing uses this same
3705+
# trick (utils.py:629–635).
3706+
bitcoind.rpc.prioritisetransaction(funding_txid, None, -10**8)
3707+
3708+
# Initiate mutual close while still in CHANNELD_AWAITING_LOCKIN
3709+
# (BOLT 2 §"Closing Initiation: shutdown" permits this).
3710+
l1.rpc.close(l2.info['id'])
3711+
3712+
# Both sides should reach CLOSINGD_COMPLETE
3713+
wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['state']
3714+
== 'CLOSINGD_COMPLETE')
3715+
wait_for(lambda: only_one(l2.rpc.listpeerchannels()['channels'])['state']
3716+
== 'CLOSINGD_COMPLETE')
3717+
3718+
# Advance the chain. CLN waits for an on-chain funding-spend event
3719+
# (not a block count), so 100 blocks is sufficient to demonstrate the
3720+
# stuck state.
3721+
bitcoind.generate_block(100)
3722+
sync_blockheight(bitcoind, [l1, l2])
3723+
3724+
# Sanity: funding really never confirmed
3725+
assert l1.rpc.listpeerchannels()['channels'][0].get('short_channel_id') is None
3726+
assert l2.rpc.listpeerchannels()['channels'][0].get('short_channel_id') is None
3727+
3728+
# Expected behavior under fix: channel record has moved beyond
3729+
# CLOSINGD_COMPLETE (transitioned to ONCHAIN with proof-of-give-up,
3730+
# been auto-forgotten, or some other resolved terminal state).
3731+
chans_l1 = l1.rpc.listpeerchannels()['channels']
3732+
chans_l2 = l2.rpc.listpeerchannels()['channels']
3733+
assert all(c['state'] != 'CLOSINGD_COMPLETE' for c in chans_l1), (
3734+
f"l1 still has channel in CLOSINGD_COMPLETE after 100 blocks: "
3735+
f"{[c['state'] for c in chans_l1]}"
3736+
)
3737+
assert all(c['state'] != 'CLOSINGD_COMPLETE' for c in chans_l2), (
3738+
f"l2 still has channel in CLOSINGD_COMPLETE after 100 blocks: "
3739+
f"{[c['state'] for c in chans_l2]}"
3740+
)
3741+
3742+
36643743
def test_close_weight_estimate(node_factory, bitcoind):
36653744
"""closingd uses the expected closing tx weight to constrain fees; make sure that lightningd agrees
36663745
once it has the actual agreed tx"""

0 commit comments

Comments
 (0)