From 22de3ee3eacbbcce581958f7146f90151c83b3d9 Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Thu, 15 Oct 2026 19:08:31 -0400 Subject: [PATCH 1/3] lightningd: sign a splice commitment with the splice's remote funding key --- lightningd/peer_control.c | 39 ++++++++++++++++++++++--------- tests/test_splicing.py | 49 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/lightningd/peer_control.c b/lightningd/peer_control.c index 4e6bc304f476..3376998d9570 100644 --- a/lightningd/peer_control.c +++ b/lightningd/peer_control.c @@ -246,7 +246,8 @@ u8 *p2tr_for_keyidx(const tal_t *ctx, struct lightningd *ld, u64 keyidx) static struct bitcoin_tx *sign_last_tx(const tal_t *ctx, const struct channel *channel, const struct bitcoin_tx *last_tx, - const struct bitcoin_signature *last_sig) + const struct bitcoin_signature *last_sig, + const struct pubkey *remote_fundingkey) { struct lightningd *ld = channel->peer->ld; struct bitcoin_signature sig; @@ -262,8 +263,7 @@ static struct bitcoin_tx *sign_last_tx(const tal_t *ctx, &channel->peer->id, channel->dbid, tx, - &channel->channel_info - .remote_fundingkey, + remote_fundingkey, commit_index); msg = hsm_sync_req(tmpctx, ld, take(msg)); @@ -273,13 +273,24 @@ static struct bitcoin_tx *sign_last_tx(const tal_t *ctx, witness = bitcoin_witness_2of2(tx, last_sig, - &sig, &channel->channel_info.remote_fundingkey, + &sig, remote_fundingkey, &channel->local_funding_pubkey); bitcoin_tx_input_set_witness(tx, 0, take(witness)); return tx; } +/* A splice commitment spends the splice's funding output, which uses the + * funding key the peer gave in splice_init/splice_ack. */ +static const struct pubkey * +inflight_remote_fundingkey(const struct channel *channel, + const struct channel_inflight *inflight) +{ + if (inflight->funding->splice_remote_funding) + return inflight->funding->splice_remote_funding; + return &channel->channel_info.remote_fundingkey; +} + bool invalid_last_tx(const struct bitcoin_tx *tx) { /* This problem goes back further, but was discovered just before the @@ -318,13 +329,14 @@ static struct bitcoin_tx *sign_and_send_last(const tal_t *ctx, struct channel *channel, const char *cmd_id, const struct bitcoin_tx *last_tx, - const struct bitcoin_signature *last_sig) + const struct bitcoin_signature *last_sig, + const struct pubkey *remote_fundingkey) { struct bitcoin_txid txid; struct anchor_details *adet; struct bitcoin_tx *tx; - tx = sign_last_tx(ctx, channel, last_tx, last_sig); + tx = sign_last_tx(ctx, channel, last_tx, last_sig, remote_fundingkey); bitcoin_txid(tx, &txid); wallet_transaction_add(ld->wallet, tx->wtx, 0, 0); wallet_extract_owned_outputs(ld->wallet, tx->wtx, false, NULL, NULL); @@ -500,18 +512,21 @@ void drop_to_chain(struct lightningd *ld, struct channel *channel, channel, cmd_id, inflight->last_tx, - &inflight->last_sig)); + &inflight->last_sig, + inflight_remote_fundingkey(channel, inflight))); } if (!have_current) tal_arr_expand(&txs, sign_and_send_last(tmpctx, ld, channel, cmd_id, channel->last_tx, - &channel->last_sig)); + &channel->last_sig, + &channel->channel_info.remote_fundingkey)); } else { tal_arr_expand(&txs, sign_and_send_last(tmpctx, ld, channel, cmd_id, channel->last_tx, - &channel->last_sig)); + &channel->last_sig, + &channel->channel_info.remote_fundingkey)); } resolve_close_command(ld, channel, cooperative, txs); @@ -4114,7 +4129,8 @@ static struct command_result *json_sign_last_tx(struct command *cmd, log_debug(channel->log, "dev-sign-last-tx: signing tx with %zu outputs", channel->last_tx->wtx->num_outputs); - tx = sign_last_tx(cmd, channel, channel->last_tx, &channel->last_sig); + tx = sign_last_tx(cmd, channel, channel->last_tx, &channel->last_sig, + &channel->channel_info.remote_fundingkey); json_add_tx(response, "tx", tx); /* If we've got inflights, return them */ @@ -4126,7 +4142,8 @@ static struct command_result *json_sign_last_tx(struct command *cmd, if (inflight->splice_locked_memonly) continue; tx = sign_last_tx(cmd, channel, inflight->last_tx, - &inflight->last_sig); + &inflight->last_sig, + inflight_remote_fundingkey(channel, inflight)); json_object_start(response, NULL); json_add_txid(response, "funding_txid", &inflight->funding->outpoint.txid); diff --git a/tests/test_splicing.py b/tests/test_splicing.py index 461265fc04df..1e7081e4b97a 100644 --- a/tests/test_splicing.py +++ b/tests/test_splicing.py @@ -1241,6 +1241,55 @@ def test_splice_unconfirmed_force_close_publishes_current(node_factory, bitcoind r'Broadcasting txid {}'.format(inflight['scratch_txid'])]) +@pytest.mark.openchannel('v1') +@pytest.mark.openchannel('v2') +@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need') +def test_splice_force_close_uses_splice_remote_fundingkey(node_factory, bitcoind): + """A force close must sign a splice commitment with the splice's funding key. + + A peer may give a new funding key in splice_init/splice_ack. Until the + splice locks, the channel still holds the peer's old key, and signing the + splice's commitment with it gives a witness that can't spend the splice + output: a peer that withholds splice_locked leaves us no valid close. + """ + l1, l2 = node_factory.line_graph(2, fundamount=1000000, + announce_channels=False, + opts={'may_reconnect': True, + 'allow_warning': True}) + chan_id = l1.get_channel_id(l2) + + funds_result = l1.rpc.fundpsbt("109000sat", 0, 0, excess_as_change=True) + result = l1.rpc.splice_init(chan_id, 100000, funds_result['psbt']) + result = l1.rpc.splice_update(chan_id, result['psbt']) + assert result['commitments_secured'] is False + result = l1.rpc.splice_update(chan_id, result['psbt']) + assert result['commitments_secured'] is True + result = l1.rpc.signpsbt(result['psbt']) + splice_txid = l1.rpc.splice_signed(chan_id, result['signed_psbt'])['txid'] + l1.daemon.wait_for_log(' to CHANNELD_AWAITING_SPLICE') + l2.daemon.wait_for_log(' to CHANNELD_AWAITING_SPLICE') + + # l2 withholds splice_locked while the splice confirms. + l2.stop() + bitcoind.generate_block(6, wait_for_mempool=splice_txid) + + # CLN doesn't rotate its funding key, so make the channel's remote key + # differ from the splice's as if l2 had given a new one for the splice. + l1.stop() + l1.db_manip("UPDATE channels SET fundingkey_remote=" + "X'0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'") + l1.start() + + inflight = only_one(l1.rpc.dev_sign_last_tx(l2.info['id'])['inflights']) + assert inflight['funding_txid'] == splice_txid + commit_txid = bitcoind.rpc.decoderawtransaction(inflight['tx'])['txid'] + + l1.rpc.dev_fail(l2.info['id']) + l1.daemon.wait_for_log('Peer permanent failure') + bitcoind.generate_block(1, wait_for_mempool=commit_txid) + l1.daemon.wait_for_log(r'Resolved FUNDING_TRANSACTION/FUNDING_OUTPUT by OUR_UNILATERAL') + + @unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need') def test_splice_candidate_spent_before_lock(node_factory, bitcoind): """A confirmed splice output spent before splice_locked must be adopted. From f3b96aef44c220eff70214df4f95928c43103e34 Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Sun, 18 Oct 2026 23:24:49 -0400 Subject: [PATCH 2/3] channeld: give hsmd the splice's remote funding key when setting up a splice --- channeld/channeld.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/channeld/channeld.c b/channeld/channeld.c index e9f9ead02521..a3d03deb97c0 100644 --- a/channeld/channeld.c +++ b/channeld/channeld.c @@ -4449,7 +4449,7 @@ static void update_hsmd_with_splice(struct peer *peer, struct inflight *inflight /*local_upfront_shutdown_script*/ NULL, /*local_upfront_shutdown_wallet_index*/ NULL, &peer->channel->basepoints[REMOTE], - &peer->channel->funding_pubkey[REMOTE], + &inflight->remote_funding, peer->channel->config[REMOTE].to_self_delay, /*remote_upfront_shutdown_script*/ NULL, peer->channel->type); From 410184e09c24e703254e7c9f5b120cc896a36b7c Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Sun, 18 Oct 2026 23:53:48 -0400 Subject: [PATCH 3/3] lightningd: watch a dual-funded RBF candidate's output with the channel's funding key on mutual close --- lightningd/peer_control.c | 2 +- tests/test_opening.py | 33 +++++++++++++++++++++++++++++++++ tests/test_splicing.py | 1 + 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lightningd/peer_control.c b/lightningd/peer_control.c index 3376998d9570..a97d9f4d8683 100644 --- a/lightningd/peer_control.c +++ b/lightningd/peer_control.c @@ -544,7 +544,7 @@ void drop_to_chain(struct lightningd *ld, struct channel *channel, } const u8 *funding_wscript = bitcoin_redeem_2of2(tmpctx, &channel->local_funding_pubkey, - inflight->funding->splice_remote_funding); + inflight_remote_fundingkey(channel, inflight)); watch_scriptpubkey(inflight, ld->topology, take(scriptpubkey_p2wsh(NULL, funding_wscript)), &inflight->funding->outpoint, diff --git a/tests/test_opening.py b/tests/test_opening.py index efddfc759549..cf8dedd19253 100644 --- a/tests/test_opening.py +++ b/tests/test_opening.py @@ -4468,3 +4468,36 @@ def v0(psbt): n.daemon.wait_for_log(' to CHANNELD_NORMAL') # The bump is what confirmed. assert only_one(l1.rpc.listpeerchannels()['channels'])['funding_txid'] != res['txid'] + + +@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need') +@pytest.mark.openchannel('v2') +def test_rbf_mutual_close_before_lockin(node_factory, bitcoind): + """A mutual close of a dual-funded channel with an RBF candidate + pending watches every candidate's output.""" + l1, l2 = node_factory.get_nodes(2) + l1.rpc.connect(l2.info['id'], 'localhost', l2.port) + chan_amount = 100000 + l1.fundwallet(2**24) + + res = l1.rpc.fundchannel(l2.info['id'], chan_amount) + chan_id = res['channel_id'] + vin = only_one(bitcoind.rpc.decoderawtransaction(res['tx'])['vin']) + l1.daemon.wait_for_log(' to DUALOPEND_AWAITING_LOCKIN') + + initpsbt = l1.rpc.utxopsbt(chan_amount, find_next_feerate(l1, l2), 42 + 172, + ["{}:{}".format(vin['txid'], vin['vout'])], + reservedok=True, excess_as_change=True)['psbt'] + bump = l1.rpc.openchannel_bump(chan_id, chan_amount, initpsbt) + update = l1.rpc.openchannel_update(chan_id, bump['psbt']) + assert update['commitments_secured'] + l1.rpc.openchannel_signed(chan_id, l1.rpc.signpsbt(update['psbt'])['signed_psbt']) + wait_for(lambda: len(only_one(l1.rpc.listpeerchannels()['channels'])['inflight']) == 2) + + l1.rpc.close(chan_id) + for n in (l1, l2): + wait_for(lambda: only_one(n.rpc.listpeerchannels()['channels'])['state'] == 'CLOSINGD_COMPLETE') + + # Startup resends the close, which takes the same path. + l2.restart() + assert only_one(l2.rpc.listpeerchannels()['channels'])['state'] == 'CLOSINGD_COMPLETE' diff --git a/tests/test_splicing.py b/tests/test_splicing.py index 1e7081e4b97a..fd72ca7f883f 100644 --- a/tests/test_splicing.py +++ b/tests/test_splicing.py @@ -1244,6 +1244,7 @@ def test_splice_unconfirmed_force_close_publishes_current(node_factory, bitcoind @pytest.mark.openchannel('v1') @pytest.mark.openchannel('v2') @unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need') +@unittest.skipIf(os.getenv('TEST_DB_PROVIDER', 'sqlite3') != 'sqlite3', "modifies database, which is assumed sqlite3") def test_splice_force_close_uses_splice_remote_fundingkey(node_factory, bitcoind): """A force close must sign a splice commitment with the splice's funding key.