Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions tests/test_opening.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]}"
)
Loading