From 2300075086f39b59c3d2ef4952e5542cbd298fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Tue, 18 Aug 2026 15:39:57 +0200 Subject: [PATCH 1/4] tests: cover fee rescue input selection with an immature coinbase wallet_utxo_boost() picks the wallet inputs which fund anchor and HTLC fee rescue transactions. Ordinary coin selection filters immature coinbase outputs via deep_enough(); a rescue funded by one cannot confirm, so rescue selection needs the same filter. Give the closing node a single immature block reward as its only wallet output, and check that it declines to boost rather than selecting it. Marked xfail(strict=True) since the selector is not yet fixed; the next commit removes the marker. (cherry picked from commit daa5654d480381147f9084c6557b972d023a77e4) --- tests/test_closing.py | 49 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_closing.py b/tests/test_closing.py index d5a5b382e63e..395c180b8696 100644 --- a/tests/test_closing.py +++ b/tests/test_closing.py @@ -4878,6 +4878,55 @@ def test_anchorspend_using_to_remote(node_factory, bitcoind, anchors): bitcoind.generate_block(1, wait_for_mempool=2) +@pytest.mark.xfail(strict=True) +@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd anchors not supported') +def test_anchorspend_ignores_immature_coinbase(node_factory, bitcoind, executor): + """Fee rescue must not select an immature coinbase: spending one is + consensus-invalid, so the resulting anchor spend would be rejected.""" + # l1 stops responding once it has the fulfill, so l2 has to go onchain + # to claim before the incoming HTLC expires. + l1, l2 = node_factory.get_nodes(2, + opts=[{'disconnect': ['-WIRE_REVOKE_AND_ACK*2'], + 'dev-no-reconnect': None, + 'feerates': (1000,) * 4}, + {'feerates': (1000,) * 4}]) + + # l2's only wallet output is a block reward it just mined: immature, and + # thus unspendable, for the next 100 blocks. + coinbase_block = only_one(bitcoind.rpc.generatetoaddress(1, l2.rpc.newaddr('bech32')['bech32'])) + sync_blockheight(bitcoind, [l1, l2]) + coinbase_txid = bitcoind.rpc.getblock(coinbase_block)['tx'][0] + assert only_one([o for o in l2.rpc.listfunds()['outputs'] + if o['txid'] == coinbase_txid])['status'] == 'immature' + + # l1 funds the channel, so l2 acquires no other wallet output. + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + l1.fundchannel(l2, 1000000) + + inv = l2.rpc.invoice(200000000, 'stuck', 'stuck') + executor.submit(l1.rpc.xpay, inv['bolt11']) + l1.daemon.wait_for_log('dev_disconnect: -WIRE_REVOKE_AND_ACK') + + # Now make the commitment tx feerate inadequate, so the close needs a + # wallet-funded boost, and mine until l2 hits the HTLC deadline. + l2.set_feerates((7500,) * 4) + for _ in range(20): + bitcoind.generate_block(1) + sync_blockheight(bitcoind, [l2]) + if only_one(l2.rpc.listpeerchannels()['channels'])['state'] == 'AWAITING_UNILATERAL': + break + wait_for(lambda: only_one(l2.rpc.listpeerchannels()['channels'])['state'] == 'AWAITING_UNILATERAL') + + # The immature coinbase is the only candidate, so there is nothing to + # boost with: l2 must say so rather than build an invalid rescue. + l2.daemon.wait_for_log('No utxos to bump commit_tx') + assert not l2.daemon.is_in_log('Creating anchor spend for local commit tx') + + # And it's still sitting there, unspent. + assert only_one([o for o in l2.rpc.listfunds()['outputs'] + if o['txid'] == coinbase_txid])['status'] == 'immature' + + def test_onchain_reestablish_reply(node_factory, bitcoind, executor): l1, l2, l3 = node_factory.line_graph(3, opts={'may_reconnect': True, 'dev-no-reconnect': None}) From 41289b2033f646f37ca0da4b3cf30e6ba45942d7 Mon Sep 17 00:00:00 2001 From: cdecker Date: Tue, 18 Aug 2026 15:40:15 +0200 Subject: [PATCH 2/4] wallet: exclude immature coinbase outputs from fee rescue selection wallet_utxo_boost() selects wallet-funded inputs for anchor and HTLC fee rescue transactions, but unlike ordinary coin selection it never applied the coinbase-maturity check. An immature coinbase could therefore be the sole rescue input, producing a consensus-invalid transaction that Bitcoin Core rejects as a premature coinbase spend. Apply the same utxo_is_immature() filter used by deep_enough()/ordinary selection. Since both call sites pass the live blockheight and rebuild on reorg/RBF, maturity is revalidated at construction and after reorgs. This makes test_anchorspend_ignores_immature_coinbase pass, so its xfail marker is dropped. Changelog-Fixed: Wallet: anchor and HTLC fee rescue no longer select immature coinbase outputs, which produced a transaction bitcoind rejects (cherry picked from commit 5e79baacc1c900c29acfae852f6233095872e309) --- tests/test_closing.py | 1 - wallet/wallet.c | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_closing.py b/tests/test_closing.py index 395c180b8696..c9d0b4512a0c 100644 --- a/tests/test_closing.py +++ b/tests/test_closing.py @@ -4878,7 +4878,6 @@ def test_anchorspend_using_to_remote(node_factory, bitcoind, anchors): bitcoind.generate_block(1, wait_for_mempool=2) -@pytest.mark.xfail(strict=True) @unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd anchors not supported') def test_anchorspend_ignores_immature_coinbase(node_factory, bitcoind, executor): """Fee rescue must not select an immature coinbase: spending one is diff --git a/wallet/wallet.c b/wallet/wallet.c index bb66fb44ebf5..bf17a855432d 100644 --- a/wallet/wallet.c +++ b/wallet/wallet.c @@ -631,6 +631,11 @@ struct utxo **wallet_utxo_boost(const tal_t *ctx, if (utxo_is_csv_locked(utxo, blockheight)) continue; + /* Don't add immature coinbase outputs: spending them is + * consensus-invalid. */ + if (utxo_is_immature(utxo, blockheight)) + continue; + /* UTXOs must be sane amounts */ if (!amount_sat_add(&new_excess_sats, excess_sats, utxo->amount)) From 79273e6ae1e6c6cfda69cbe9b6801ec52c98114b Mon Sep 17 00:00:00 2001 From: cdecker Date: Fri, 7 Aug 2026 17:16:23 +0200 Subject: [PATCH 3/4] wallet: bound-check shachain_known pos on load Keep the loaded pos within the known[] array like the other defensive checks in the channel loader, so a malformed row fails loudly instead of indexing out of range. Co-Authored-By: Claude Opus 5 Changelog-Fixed: wallet: an out-of-range shachain index in the database is now rejected at load instead of read out of bounds. (cherry picked from commit fdd7b11e9f38eb2d3fa5515c00e64f0ad17d63fc) --- wallet/wallet.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/wallet/wallet.c b/wallet/wallet.c index bf17a855432d..49c94bafc2d3 100644 --- a/wallet/wallet.c +++ b/wallet/wallet.c @@ -1255,6 +1255,9 @@ static bool wallet_shachain_load(struct wallet *wallet, u64 id, while (db_step(stmt)) { int pos = db_col_int(stmt, "pos"); + if (pos < 0 || pos >= ARRAY_SIZE(chain->chain.known)) + db_fatal(wallet->db, + "shachain_known pos %i out of range", pos); chain->chain.known[pos].index = db_col_u64(stmt, "idx"); db_col_sha256(stmt, "hash", &chain->chain.known[pos].hash); } From 22fa10fbe18c70fa489c9d437ee46dd94e8fc1e8 Mon Sep 17 00:00:00 2001 From: cdecker Date: Fri, 7 Aug 2026 17:16:24 +0200 Subject: [PATCH 4/4] db: tidy db_col_psbt NULL handling Return early for a NULL column like the sibling db_col_* helpers, and only parse and set the version when there is actual data to work with. Co-Authored-By: Claude Opus 5 Changelog-None (cherry picked from commit d6f8cc32eddb5f9aaba4c0561f94944b550cb61f) --- db/bindings.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/db/bindings.c b/db/bindings.c index d61fca9f49cb..41aba612a680 100644 --- a/db/bindings.c +++ b/db/bindings.c @@ -496,9 +496,12 @@ struct wally_psbt *db_col_psbt(const tal_t *ctx, struct db_stmt *stmt, const cha const u8 *src = db_column_blob(stmt, col); size_t len = db_column_bytes(stmt, col); - db_column_null_warn(stmt, colname, col); + if (db_column_null_warn(stmt, colname, col)) + return NULL; + psbt = psbt_from_bytes(ctx, src, len); - psbt_set_version(psbt, 2); + if (psbt) + psbt_set_version(psbt, 2); return psbt; }