tests: test funds don't get stuck as reserved after `withdraw` fails due to broadcast rejection (e.g. feerate below mempoolminfee).
What changed, and why it matters
This commit only adds a new test case that demonstrates a known bug in Core Lightning's wallet handling. When a `withdraw` command fails because the Bitcoin node rejects the transaction broadcast (for example, the fee is too low), some funds can remain marked as 'reserved' even though they were never actually spent. The test documents the bug and includes a manual workaround using `unreserveinputs`. It does not fix the underlying issue, so user funds can appear stuck until manually unreserved.
Treat this as a bug report with a reproducer rather than a security patch. The project should fix the reservation cleanup logic so that `fundpsbt`'s reservation is released when `withdraw` fails due to broadcast rejection. Users who encounter stuck reserved funds can use the `unreserveinputs` workaround shown in the test until a fix is released.
Security signals we found
Funds can be marked reserved after a failed broadcast, making them temporarily unavailable for spending
Known bug is explicitly documented in test comments
No production code fix is included in the commit
Workaround requires manual RPC intervention (`unreserveinputs`)
Could affect wallet availability/DoS under low-fee conditions
Evidence from the diff
The diff adds test_withdraw_stuck_reserved_on_broadcast_failure to tests/test_wallet.py. The test mocks sendrawtransaction to return a -26 ‘min relay fee not met’ error, simulating a broadcast rejection. After withdraw fails, the test asserts that UTXOs are still reserved, explicitly calling this a known bug: sendpsbt_done unreserves its own reservation, but fundpsbt’s earlier reservation is not cleaned up. The test then shows a workaround by building a PSBT from the stuck UTXOs and calling unreserveinputs. No production code is changed.
Changed components
walletwithdraw RPCfundpsbt / sendpsbt_done reservation logictests/test_wallet.pyInspect captured patch +64 / −0
diff --git a/tests/test_wallet.py b/tests/test_wallet.py
index e4d1cc4f..0eb0433c 100644
--- a/tests/test_wallet.py
+++ b/tests/test_wallet.py
@@ -2785,3 +2785,67 @@ def test_rescan_missing_utxo(node_factory, bitcoind):
time.sleep(5)
assert not l1.daemon.is_in_log("Scanning for missed UTXOs", start=oldstart_l1)
assert not l3.daemon.is_in_log("Scanning for missed UTXOs", start=oldstart_l3)
+
+
+@unittest.skipIf(TEST_NETWORK != 'regtest', "Uses regtest-specific address types")
+def test_withdraw_stuck_reserved_on_broadcast_failure(node_factory, bitcoind):
+ """Test funds don't get stuck as reserved after withdraw fails due to
+ broadcast rejection (e.g. feerate below mempoolminfee).
+
+ """
+ l1 = node_factory.get_node(random_hsm=True)
+ addr = l1.rpc.newaddr('p2tr')['p2tr']
+
+ # Fund the node
+ bitcoind.rpc.sendtoaddress(addr, 0.01)
+ bitcoind.generate_block(1)
+ wait_for(lambda: len(l1.rpc.listfunds()['outputs']) == 1)
+
+ output = only_one(l1.rpc.listfunds()['outputs'])
+ assert output['status'] == 'confirmed'
+ assert not output.get('reserved', False)
+
+ waddr = bitcoind.rpc.getnewaddress()
+
+ # Mock sendrawtransaction to simulate bitcoind rejecting the transaction
+ # because the feerate is below its mempoolminfee
+ def mock_fail_sendrawtx(r):
+ # Self-remove after first call so subsequent transactions aren't blocked
+ l1.daemon.rpcproxy.mock_rpc('sendrawtransaction', None)
+ return {
+ 'id': r['id'],
+ 'error': {
+ 'code': -26,
+ 'message': 'min relay fee not met, 253 < 5000',
+ },
+ 'result': None,
+ }
+
+ l1.daemon.rpcproxy.mock_rpc('sendrawtransaction', mock_fail_sendrawtx)
+
+ with pytest.raises(RpcError, match=r'Error broadcasting transaction'):
+ l1.rpc.withdraw(waddr, 'all')
+
+ # BUG: UTXOs remain reserved despite the failed broadcast.
+ # sendpsbt_done correctly unreserves the reservation it added (72 blocks),
+ # but fundpsbt's prior reservation (72 blocks) is NOT cleaned up.
+ outputs = l1.rpc.listfunds()['outputs']
+ reserved = [o for o in outputs if o.get('reserved', False)]
+ assert len(reserved) > 0, \
+ "Expected UTXOs to be reserved after failed broadcast (known bug)"
+
+ with pytest.raises(RpcError, match=r'Could not afford'):
+ l1.rpc.withdraw(waddr, 'all')
+
+ # Workaround: build a PSBT from the stuck UTXOs and call unreserveinputs.
+ stuck_utxos = [{'txid': o['txid'], 'vout': o['output']} for o in reserved]
+ psbt = bitcoind.rpc.createpsbt(stuck_utxos, [])
+ l1.rpc.unreserveinputs(psbt)
+
+ outputs = l1.rpc.listfunds()['outputs']
+ assert not any(o.get('reserved', False) for o in outputs)
+
+ l1.rpc.withdraw(waddr, 'all')
+ bitcoind.generate_block(1)
+ sync_blockheight(bitcoind, [l1])
+ assert l1.db_query('SELECT COUNT(*) as c FROM outputs WHERE status=0')[0]['c'] == 0
Why this scored 44/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.