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
236 changes: 236 additions & 0 deletions tests/test_closing.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from decimal import Decimal
from fixtures import * # noqa: F401,F403
from pyln.client import RpcError, Millisatoshi
from shutil import copyfile
Expand Down Expand Up @@ -3661,6 +3662,241 @@ def test_close_twice(node_factory, executor):
assert fut2.result(TIMEOUT)['type'] == 'mutual'


@pytest.mark.xfail(
strict=True,
reason="Bug: channel stuck in CLOSINGD_COMPLETE if funding never confirms"
)
def test_closingd_complete_stuck_no_funding(node_factory, bitcoind):
"""Mutual close pre-lockin + funding never confirms → permanent CLOSINGD_COMPLETE.

BOLT 2 explicitly permits sending `shutdown` before `channel_ready`
(i.e. before the funding tx has reached `minimum_depth`). Both sides
happily complete the mutual close negotiation and persist a
fully-signed close tx. If the funding tx then never confirms, the
close tx is permanently invalid — its only input is a 2-of-2 funding
output that does not exist on chain. The state machine has no path
from CLOSINGD_COMPLETE to FUNDING_SPEND_SEEN / ONCHAIN, and there is
no cleanup. The channel record sits in CLOSINGD_COMPLETE
indefinitely.

This test demonstrates the stuck state. It is marked xfail-strict
because no fix yet exists; once fixed, the marker should be removed.
"""
l1, l2 = node_factory.line_graph(2, fundchannel=False)

# Fund l1's on-chain wallet
l1.fundwallet(10**7)

# Open the channel: funding tx is broadcast to bitcoind's mempool,
# but we do NOT mine it.
res = l1.rpc.fundchannel(l2.info['id'], 10**6)
funding_txid = res['txid']

# 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')

# Confirm the funding tx is in the mempool but NOT yet in any block
assert funding_txid in bitcoind.rpc.getrawmempool()

# Push funding's effective fee far below any block-min-fee so future
# generated blocks do not include it. pyln-testing uses this same
# trick (utils.py:629–635).
bitcoind.rpc.prioritisetransaction(funding_txid, None, -10**8)

# Initiate mutual close while still in CHANNELD_AWAITING_LOCKIN
# (BOLT 2 §"Closing Initiation: shutdown" permits this).
l1.rpc.close(l2.info['id'])

# Both sides should reach CLOSINGD_COMPLETE
wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['state']
== 'CLOSINGD_COMPLETE')
wait_for(lambda: only_one(l2.rpc.listpeerchannels()['channels'])['state']
== 'CLOSINGD_COMPLETE')

# Advance the chain. CLN waits for an on-chain funding-spend event
# (not a block count), so 100 blocks is sufficient to demonstrate the
# stuck state.
bitcoind.generate_block(100)
sync_blockheight(bitcoind, [l1, l2])

# Sanity: funding really never confirmed
assert l1.rpc.listpeerchannels()['channels'][0].get('short_channel_id') is None
assert l2.rpc.listpeerchannels()['channels'][0].get('short_channel_id') is None

# Expected behavior under fix: channel record has moved beyond
# CLOSINGD_COMPLETE (transitioned to ONCHAIN with proof-of-give-up,
# been auto-forgotten, or some other resolved terminal state).
chans_l1 = l1.rpc.listpeerchannels()['channels']
chans_l2 = l2.rpc.listpeerchannels()['channels']
assert all(c['state'] != 'CLOSINGD_COMPLETE' for c in chans_l1), (
f"l1 still has channel in CLOSINGD_COMPLETE after 100 blocks: "
f"{[c['state'] for c in chans_l1]}"
)
assert all(c['state'] != 'CLOSINGD_COMPLETE' for c in chans_l2), (
f"l2 still has channel in CLOSINGD_COMPLETE after 100 blocks: "
f"{[c['state'] for c in chans_l2]}"
)


# Bitcoin's coinbase maturity rule: coinbase outputs are not spendable
# until 100 confirmations, the canonical "reorg-safe" depth. We mine
# the double-spend to this depth so the assertion is robust against
# any "but a reorg could undo it" objection — at this depth no Bitcoin
# convention treats the spend as still reversible.
COINBASE_MATURITY = 100


@pytest.mark.xfail(
strict=True,
reason="Bug: channel stuck in CLOSINGD_COMPLETE even when funding inputs are demonstrably double-spent at coinbase-maturity depth"
)
def test_closingd_complete_stuck_funding_inputs_double_spent(node_factory, bitcoind):
"""Mutual close pre-lockin + funding inputs double-spent → permanent CLOSINGD_COMPLETE.

The sibling test (test_closingd_complete_stuck_no_funding)
demonstrates the channel stays stuck while the funding tx is
merely unmined (low priority, won't be included in blocks).

That leaves an escape hatch in the policy argument: as long as
the funding inputs remain unspent, the funding tx could in
principle still confirm if reprioritised, so the state-machine
wait is defensible.

This test removes that escape hatch. After both sides reach
CLOSINGD_COMPLETE we:

1. Capture the funding tx via the proxy mock (it never reaches
bitcoind's mempool).
2. Force-unreserve the funding inputs (the funding-tx
reservation is ~2016 blocks, so we explicitly pass a large
reserve= value to push reserved_til below current height).
3. Spend the same UTXOs in a separate withdraw tx that DOES
land on chain (the proxy mock forwards non-funding-tx
broadcasts).
4. Mature the double-spend 100 blocks past confirmation,
matching Bitcoin's coinbase maturity rule (the canonical
reorg-safe depth).

At this point the funding tx is provably and permanently invalid;
no Bitcoin convention treats the spend as still reversible. Yet
CLN keeps the channel record stuck in CLOSINGD_COMPLETE.

Marked xfail-strict because no fix yet exists; once fixed, the
marker should be removed.
"""
l1, l2 = node_factory.line_graph(2, fundchannel=False)
l1.fundwallet(10**7)

# Capture-and-censor mock. Stash the first sendrawtransaction
# (the funding tx) and censor any re-broadcast of the same hex.
# Other sendrawtransaction calls (the close tx CLN may attempt to
# broadcast, and our double-spend withdraw) are forwarded to
# bitcoind so they land on chain when valid.
captured = []

def censor(r):
raw = r['params'][0]
if not captured:
captured.append(raw)
return {'id': r['id'], 'result': {}}
if raw == captured[0]:
return {'id': r['id'], 'result': {}}
try:
txid = bitcoind.rpc.sendrawtransaction(raw)
return {'id': r['id'], 'result': txid, 'error': None}
except Exception as e:
return {'id': r['id'], 'error': {'code': -32603, 'message': str(e)}}
l1.daemon.rpcproxy.mock_rpc('sendrawtransaction', censor)

# Open the channel — funding tx is captured + censored.
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')
assert len(captured) > 0, "funding tx was not captured"

# Initiate mutual close while still in CHANNELD_AWAITING_LOCKIN
# (BOLT 2 §"Closing Initiation: shutdown" permits this).
l1.rpc.close(l2.info['id'])

# Both sides should reach CLOSINGD_COMPLETE
wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['state']
== 'CLOSINGD_COMPLETE')
wait_for(lambda: only_one(l2.rpc.listpeerchannels()['channels'])['state']
== 'CLOSINGD_COMPLETE')

# Decode the captured funding tx to extract its inputs.
decoded = bitcoind.rpc.decoderawtransaction(captured[0])
funding_inputs = [f"{vin['txid']}:{vin['vout']}" for vin in decoded['vin']]

# The funding-tx reservation marks these UTXOs as reserved for
# ~2016 blocks (the dual-open auto-unreserve interval), which
# blocks withdraw from selecting them. Force-unreserve via a
# PSBT with the same inputs and a `reserve` value large enough
# to push reserved_til back below the current block height.
# This mirrors what would happen naturally after 2016 blocks
# pass, but compresses the test runtime. The PSBT outputs are
# placeholders; only the input set matters for unreserveinputs.
psbt_inputs = [{'txid': vin['txid'], 'vout': vin['vout']}
for vin in decoded['vin']]
total_sat = sum(
int(bitcoind.rpc.getrawtransaction(vin['txid'], True)
['vout'][vin['vout']]['value'] * Decimal(100_000_000))
for vin in decoded['vin']
)
dummy = bitcoind.rpc.getnewaddress()
dummy_psbt = bitcoind.rpc.createpsbt(
psbt_inputs,
[{dummy: float(Decimal(total_sat - 1000) / Decimal(100_000_000))}],
)
l1.rpc.unreserveinputs(dummy_psbt, reserve=10_000)

# Now spend the same UTXOs in a different tx. This goes through
# the proxy's censor mock, which forwards non-funding-tx
# broadcasts to bitcoind so the double-spend actually lands.
addr = l1.rpc.newaddr('p2tr')['p2tr']
l1.rpc.withdraw(addr, "all", utxos=funding_inputs)

# Confirm the double-spend.
bitcoind.generate_block(1)
sync_blockheight(bitcoind, [l1, l2])

# Mature past Bitcoin's coinbase maturity rule so the double-spend
# is at canonical "reorg-safe" depth. Beyond this depth there is
# no remaining "but a reorg could undo it" argument.
bitcoind.generate_block(COINBASE_MATURITY)
sync_blockheight(bitcoind, [l1, l2])

# Sanity: funding really never confirmed
assert l1.rpc.listpeerchannels()['channels'][0].get('short_channel_id') is None
assert l2.rpc.listpeerchannels()['channels'][0].get('short_channel_id') is None

# Expected behavior under fix: the channel record has been
# cleaned up on both sides. The funding tx is provably impossible
# to confirm (its inputs are spent at coinbase-maturity depth), so
# there is no reason to keep the channel record in
# CLOSINGD_COMPLETE. Any forward progress is enough; we do not
# prescribe a specific cleanup shape.
chans_l1 = l1.rpc.listpeerchannels()['channels']
chans_l2 = l2.rpc.listpeerchannels()['channels']
assert all(c['state'] != 'CLOSINGD_COMPLETE' for c in chans_l1), (
f"l1 still has channel in CLOSINGD_COMPLETE after funding "
f"inputs were double-spent and matured to "
f"{COINBASE_MATURITY + 1} confirmations: "
f"{[c['state'] for c in chans_l1]}"
)
assert all(c['state'] != 'CLOSINGD_COMPLETE' for c in chans_l2), (
f"l2 still has channel in CLOSINGD_COMPLETE after funding "
f"inputs were double-spent and matured to "
f"{COINBASE_MATURITY + 1} confirmations: "
f"{[c['state'] for c in chans_l2]}"
)


def test_close_weight_estimate(node_factory, bitcoind):
"""closingd uses the expected closing tx weight to constrain fees; make sure that lightningd agrees
once it has the actual agreed tx"""
Expand Down
Loading