fix: release txprepare reservations on send failure
What changed, and why it matters
This commit fixes a bug in Core Lightning's wallet code where, if a prepared Bitcoin transaction failed during signing or broadcast, the coins that had been set aside ('reserved') for that transaction were not released back to the wallet. The fix makes sure those reservations are cleaned up automatically on failure, so funds are not stuck and become usable again. The change also adds tests that simulate a broadcast failure and verify the funds are no longer reserved afterward.
Apply the patch and run the new regression tests. Operators who experienced stuck reserved outputs after failed withdrawals can upgrade and, if needed, manually unreserve inputs until the fix is deployed. No immediate remote exploit is indicated, but the bug can degrade wallet availability.
Security signals we found
Denial-of-service/availability impact: failed transactions could leave wallet UTXOs permanently reserved, preventing their reuse until manual intervention
Resource exhaustion pattern: reserved inputs are not spendable, so repeated failed sends could reduce usable wallet balance
Error-path resource cleanup (CWE-772, CWE-404): missing release of reserved inputs on failure
Regression tests added that simulate broadcast rejection and verify reservation release
Evidence from the diff
The patch modifies plugins/txprepare.c so that error paths after signpsbt and sendpsbt call a new txprepare_forward_error handler. That handler checks whether an unreleased_tx still holds a PSBT, copies the original error, issues an unreserveinputs RPC for the original PSBT, and only then returns the original error to the caller. Previously these paths used the generic forward_error, which did not unreserve inputs. Tests in tests/test_wallet.py are added/updated to mock sendrawtransaction failures and assert that listfunds outputs are no longer reserved after withdraw/txsend fails.
Changed components
plugins/txprepare.cwithdraw RPCtxsend RPCtxprepare RPC flowunreserveinputs RPC interactionInspect captured patch +117 / −3
diff --git a/plugins/txprepare.c b/plugins/txprepare.c
index e69b0ba..3bbc991 100644
--- a/plugins/txprepare.c
+++ b/plugins/txprepare.c
@@ -50,6 +50,45 @@ static struct wally_psbt *json_tok_psbt(const tal_t *ctx,
return psbt_from_b64(ctx, buffer + tok->start, tok->end - tok->start);
}
+struct txprepare_cleanup {
+ char *error_json;
+};
+
+static struct command_result *
+txprepare_cleanup_done(struct command *cmd,
+ const char *method UNUSED,
+ const char *buf UNUSED,
+ const jsmntok_t *result UNUSED,
+ struct txprepare_cleanup *cleanup)
+{
+ return command_err_raw(cmd, cleanup->error_json);
+}
+
+/* Immediate send flows must drop the original PSBT input reservation. */
+static struct command_result *
+txprepare_forward_error(struct command *cmd,
+ const char *method,
+ const char *buf,
+ const jsmntok_t *error,
+ struct unreleased_tx *utx)
+{
+ struct out_req *req;
+ struct txprepare_cleanup *cleanup;
+
+ if (!utx->psbt)
+ return forward_error(cmd, method, buf, error, NULL);
+
+ cleanup = tal(cmd, struct txprepare_cleanup);
+ cleanup->error_json = json_strdup(cleanup, buf, error);
+
+ req = jsonrpc_request_start(cmd, "unreserveinputs",
+ txprepare_cleanup_done,
+ txprepare_cleanup_done,
+ cleanup);
+ json_add_psbt(req->js, "psbt", utx->psbt);
+ return send_outreq(req);
+}
+
static struct command_result *param_outputs(struct command *cmd,
const char *name,
const char *buffer,
@@ -179,7 +218,8 @@ static struct command_result *signpsbt_done(struct command *cmd,
fmt_wally_psbt(tmpctx, utx->psbt));
req = jsonrpc_request_start(cmd, "sendpsbt",
- sendpsbt_done, forward_error,
+ sendpsbt_done,
+ txprepare_forward_error,
utx);
json_add_psbt(req->js, "psbt", utx->psbt);
return send_outreq(req);
@@ -229,7 +269,8 @@ static struct command_result *finish_txprepare(struct command *cmd,
/* Won't live beyond this cmd. */
tal_steal(cmd, utx);
req = jsonrpc_request_start(cmd, "signpsbt",
- signpsbt_done, forward_error,
+ signpsbt_done,
+ txprepare_forward_error,
utx);
json_add_psbt(req->js, "psbt", utx->psbt);
return send_outreq(req);
@@ -464,7 +505,8 @@ static struct command_result *json_txsend(struct command *cmd,
tal_steal(cmd, utx);
req = jsonrpc_request_start(cmd, "signpsbt",
- signpsbt_done, forward_error,
+ signpsbt_done,
+ txprepare_forward_error,
utx);
json_add_psbt(req->js, "psbt", utx->psbt);
return send_outreq(req);
diff --git a/tests/test_wallet.py b/tests/test_wallet.py
index 31f28b2..f88eefc 100644
--- a/tests/test_wallet.py
+++ b/tests/test_wallet.py
@@ -211,6 +211,33 @@ def test_withdraw(node_factory, bitcoind):
l1.rpc.withdraw(l1.rpc.newaddr("p2tr")["p2tr"], 10**5, feerate="1000perkb")
+def test_withdraw_unreserves_inputs_on_send_failure(node_factory, bitcoind):
+ amount = 10**7
+ addrtype = good_addrtype()
+ l1 = node_factory.get_node(random_hsm=True)
+ addr = l1.rpc.newaddr(addrtype)[addrtype]
+
+ bitcoind.rpc.sendtoaddress(addr, amount / 10**8)
+ bitcoind.generate_block(1)
+ wait_for(lambda: len(l1.rpc.listfunds()['outputs']) == 1)
+
+ def mock_sendrawtransaction(r):
+ return {'id': r['id'],
+ 'error': {'code': 100,
+ 'message': 'feerate below mempool minimum: 251 < 253'}}
+
+ l1.daemon.rpcproxy.mock_rpc('sendrawtransaction', mock_sendrawtransaction)
+
+ with pytest.raises(RpcError, match=r'251 < 253'):
+ l1.rpc.withdraw(bitcoind.getnewaddress(), 'all', feerate='slow')
+
+ assert not any(o['reserved'] for o in l1.rpc.listfunds()['outputs'])
+
+ l1.daemon.rpcproxy.mock_rpc('sendrawtransaction', None)
+ sent = l1.rpc.withdraw(bitcoind.getnewaddress(), 'all', feerate='slow')
+ bitcoind.rpc.getmempoolentry(sent['txid'])
+
+
def test_minconf_withdraw(node_factory, bitcoind):
"""Issue 2518: ensure that ridiculous confirmation levels don't overflow
@@ -2860,6 +2887,51 @@ def test_rescan_missing_utxo(node_factory, bitcoind):
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_unreserves_on_broadcast_failure(node_factory, bitcoind):
+ """Test withdraw releases reservations after broadcast rejection."""
+ 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')
+
+ 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
+
+
@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
Why this scored 47/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.