diff --git a/tests/test_closing.py b/tests/test_closing.py index 9dec96d7f92e..d05fc4103228 100644 --- a/tests/test_closing.py +++ b/tests/test_closing.py @@ -1,3 +1,4 @@ +from decimal import Decimal from fixtures import * # noqa: F401,F403 from pyln.client import RpcError, Millisatoshi from shutil import copyfile @@ -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"""