From c1dececd550b0331b0e12e0f0f2077326466215b Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Tue, 5 May 2026 10:08:16 -0700 Subject: [PATCH] tests: add xfail-strict reproducer for funder-side stuck CHANNELD_AWAITING_LOCKIN A user can be left with a channel wedged in a state that provably will never resolve, with no transition to a sensible end state. On the funder side this happens two ways, and CLN cleans up neither. How it gets stuck: the node funds a channel and the funding tx never confirms (rejected at broadcast for too low a fee, evicted from the mempool, or never relayed). The channel sits in CHANNELD_AWAITING_LOCKIN. CLN implements the BOLT 2 fundee forget rule (2016 blocks) but has no funder-side equivalent, and the funder always has funds at stake, so it cannot simply time the channel out. If the operator then closes the still-unlocked channel, CLN drops to chain and moves to AWAITING_UNILATERAL with a commitment tx whose only input is the never-existing funding output, so it sits stuck there instead. Why the state is held until a funding input is double-spent: while the funding inputs are unspent the funding tx can still confirm (e.g. if rebroadcast at a higher fee), and if it does the channel becomes live (or the commitment tx becomes valid and the close resolves), so the channel must be kept until the funding is known dead. Why it is safe to clear once the double-spend is buried: when a funding input is spent by another tx and that spend is buried deep enough that a reorg will not reverse it, the funding tx can never confirm. The funding output will never exist, so the channel can never make progress and can move to a clean end state. This adds two xfail-strict reproducers, one for each stuck state (CHANNELD_AWAITING_LOCKIN and AWAITING_UNILATERAL). Each drives the funder into the stuck state behind a never-confirming funding tx, double-spends a funding input, and asserts both edges: the channel must stay stuck while the double-spend is shallow, and must reach a resolved state once the spend is reorg-safe. The markers should be removed once a fix exists. Reproduces #9112. Changelog-None --- tests/test_opening.py | 298 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) diff --git a/tests/test_opening.py b/tests/test_opening.py index 82f2e6baf631..08607c8a82ae 100644 --- a/tests/test_opening.py +++ b/tests/test_opening.py @@ -6,6 +6,7 @@ ) from pyln.testing.utils import FUNDAMOUNT +from decimal import Decimal from pathlib import Path import os import pytest @@ -3089,3 +3090,300 @@ def test_no_retransmit_confirmed_funding(node_factory): # Should not have attempted (and failed) to re-broadcast the funding tx. assert not l1.daemon.is_in_log('Failed to re-transmit funding tx') assert not l1.daemon.is_in_log('Successfully rexmitted funding tx') + + +# Depth at which we treat the double-spend as final: buried this many +# blocks, a reorg reversing it is not a practical concern. 100 is a +# conservative choice (it also matches Bitcoin's coinbase-maturity rule). +REORG_SAFE_DEPTH = 100 + + +@pytest.mark.xfail( + strict=True, + reason="funder channel stays in CHANNELD_AWAITING_LOCKIN even after a funding input is double-spent and the spend is reorg-safe" +) +def test_funder_stuck_funding_inputs_double_spent(node_factory, bitcoind): + """Funder funds a channel, the funding never confirms, then a funding input is double-spent. + + A user can be left with a channel wedged in a state that provably + will never resolve, with no transition to a sensible end state. The + sequence: + + - How it gets stuck: the node funds a channel and the funding tx is + broadcast, but it never confirms (rejected at broadcast for too + low a fee, evicted from the mempool, or never relayed). The + channel sits in CHANNELD_AWAITING_LOCKIN. CLN implements the + BOLT 2 fundee forget rule (2016 blocks) but has no funder-side + equivalent, and the funder always has funds at stake, so it cannot + simply time the channel out. + + - Why the state is held until the double-spend: while the funding + inputs are unspent the funding tx can still confirm (e.g. if + rebroadcast at a higher fee), and if it does the channel becomes + live, so the channel must be kept until the funding is known dead. + + - Why it is safe to clear once the double-spend is buried: when a + funding input is spent by another tx and that spend is buried deep + enough that a reorg will not reverse it, the funding tx can never + confirm. The funding output will never exist, the channel can + never become live, and it can move to a clean end state. + + To reproduce, after the channel reaches CHANNELD_AWAITING_LOCKIN 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 past REORG_SAFE_DEPTH. + + The test asserts both edges: the channel stays in + CHANNELD_AWAITING_LOCKIN while the double-spend is shallow, and + reaches a resolved state once it is reorg-safe. + + 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 (our double-spend withdraw) are forwarded + # to bitcoind so they land on chain. + 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" + + # 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 + # below the current block height. 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) + + # 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]) + + # Safety property: with the double-spend only one block deep a reorg + # could still evict it, re-enabling the funding inputs and letting + # the funding tx confirm after all. Resolving the channel now would + # be premature, so until the spend reaches a reorg-safe depth the + # channel should stay in CHANNELD_AWAITING_LOCKIN. + assert only_one(l1.rpc.listpeerchannels()['channels'])['state'] == 'CHANNELD_AWAITING_LOCKIN', ( + "funder resolved the channel while the funding-input double-spend " + "was only one block deep; a reorg could still make the funding " + "confirmable" + ) + + # Mature the double-spend to a reorg-safe depth, so that a reorg + # reversing it is no longer a practical concern. + bitcoind.generate_block(REORG_SAFE_DEPTH) + sync_blockheight(bitcoind, [l1]) + + # Expected behaviour under fix: the funder's channel record has been + # cleaned up. The funding tx can never confirm (a funding input is + # spent at a reorg-safe depth), so there is no reason to keep the + # channel record in CHANNELD_AWAITING_LOCKIN. 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"funder still has channel in CHANNELD_AWAITING_LOCKIN after " + f"funding inputs were double-spent and matured to " + f"{REORG_SAFE_DEPTH + 1} confirmations: " + f"{[c['state'] for c in chans_l1]}" + ) + + +@pytest.mark.xfail( + strict=True, + reason="funder channel stays in AWAITING_UNILATERAL even after a funding input is double-spent and the spend is reorg-safe" +) +def test_funder_stuck_unilateral_funding_inputs_double_spent(node_factory, bitcoind): + """Funder closes an un-locked channel, the funding never confirms, then a funding input is double-spent. + + A user can be left with a channel wedged in a state that provably + will never resolve, with no transition to a sensible end state. The + sequence: + + - How it gets stuck: the node funds a channel whose funding tx never + confirms, then issues close on the still-unlocked channel (an + operator, or an automation such as CLBOSS's spenderp, trying to + tidy up). CLN drops to chain and moves to AWAITING_UNILATERAL, + trying to broadcast a commitment tx whose only input is the + funding output. That output does not exist, so the commitment tx + can never confirm and the channel sits in AWAITING_UNILATERAL. + + - Why the state is held until the double-spend: while the funding + inputs are unspent the funding tx can still confirm (e.g. if + rebroadcast at a higher fee), and if it does the commitment tx + becomes valid and the close resolves normally, so the channel must + be kept until the funding is known dead. + + - Why it is safe to clear once the double-spend is buried: when a + funding input is spent by another tx and that spend is buried deep + enough that a reorg will not reverse it, the funding tx can never + confirm. The funding output will never exist, the commitment tx + can never confirm, and the channel can move to a clean end state. + + Same reproduction as test_funder_stuck_funding_inputs_double_spent, + but with a unilateral close before the double-spend, so the stuck + state is AWAITING_UNILATERAL rather than CHANNELD_AWAITING_LOCKIN. + + 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 re-broadcasts of it. The commitment tx the + # unilateral close produces spends the never-existing funding output, + # so it can never be valid either; drop it too. Anything else (our + # double-spend withdraw) is forwarded to bitcoind so it lands. + captured = [] + funding_txid = [] + + def censor(r): + raw = r['params'][0] + if not captured: + captured.append(raw) + funding_txid.append(bitcoind.rpc.decoderawtransaction(raw)['txid']) + return {'id': r['id'], 'result': {}} + if raw == captured[0]: + return {'id': r['id'], 'result': {}} + decoded = bitcoind.rpc.decoderawtransaction(raw) + if any(vin.get('txid') == funding_txid[0] for vin in decoded['vin']): + 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" + + # Force a unilateral close. Stopping l2 keeps a mutual close from + # racing in and landing us in CLOSINGD_COMPLETE instead. The funder + # moves to AWAITING_UNILATERAL and produces a commitment tx spending + # the never-existing funding output (dropped by the mock above). + l2.stop() + l1.rpc.close(l2.info['id'], unilateraltimeout=1) + wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['state'] + == 'AWAITING_UNILATERAL') + + # 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 + # below the current block height. 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) + + # 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]) + + # Safety property: with the double-spend only one block deep a reorg + # could still evict it, re-enabling the funding inputs and letting + # the funding tx confirm after all. Resolving the channel now would + # be premature, so until the spend reaches a reorg-safe depth the + # channel should stay in AWAITING_UNILATERAL. + assert only_one(l1.rpc.listpeerchannels()['channels'])['state'] == 'AWAITING_UNILATERAL', ( + "funder resolved the channel while the funding-input double-spend " + "was only one block deep; a reorg could still make the funding " + "confirmable" + ) + + # Mature the double-spend to a reorg-safe depth, so that a reorg + # reversing it is no longer a practical concern. + bitcoind.generate_block(REORG_SAFE_DEPTH) + sync_blockheight(bitcoind, [l1]) + + # Expected behaviour under fix: the funder's channel record has been + # cleaned up. The funding tx can never confirm (a funding input is + # spent at a reorg-safe depth), so there is no reason to keep the + # channel record in AWAITING_UNILATERAL. 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"funder still has channel in AWAITING_UNILATERAL after funding " + f"inputs were double-spent and matured to " + f"{REORG_SAFE_DEPTH + 1} confirmations: " + f"{[c['state'] for c in chans_l1]}" + )