lightningd: wire up `option_simple_close` master-side handling
What changed, and why it matters
This commit turns on a new experimental Lightning feature called 'simple close' (BOLT2 option_simple_close). It adds the master-side code that starts a new subdaemon, validates mutual-close transactions, stores them, and broadcasts them instead of the older commitment transaction. The change removes 'expected to fail' markers from five integration tests, meaning the feature is now considered functional. It is a protocol implementation patch, not a fix for a known vulnerability, and it is gated behind a developer-only feature flag.
Treat this as a feature-enablement commit rather than an urgent security patch. Reviewers should focus on validation correctness in `close_tx_check()` and signature verification in `handle_simpleclosed_got_sig()` / `handle_simpleclosed_closee_broadcast()`, ensure the HSM permission is only used for legitimate close transactions, and confirm that the no-commitment-broadcast path cannot be triggered in non-simple-close flows. Because it is experimental and behind a dev flag, production risk is low unless the feature flag is enabled.
Security signals we found
New subdaemon introduced with HSM signing permission for closing transactions
Validation added for mutual close transaction inputs and output scripts
Remote signature verification before storing close transaction
Prevention of commitment tx broadcast that could RBF-replace mutual close tx
Restart retransmission path updated to avoid broadcasting commitment tx
Feature is experimental and requires --dev-force-features=+60 to enable
Evidence from the diff
The patch wires up option_simple_close in lightningd. It introduces simple_close_control.c/h to manage the lightning_simpleclosed subdaemon: peer_start_simpleclosed() initializes the daemon with funding details, feerate bounds, and shutdown scripts; handle_simpleclosed_got_sig() and handle_simpleclosed_closee_broadcast() validate that the proposed close transaction spends exactly the funding outpoint, pays only to the two shutdown scripts, and carries a valid remote signature before storing it via channel_set_last_tx(); handle_simpleclosed_complete() advances state to CLOSINGD_COMPLETE and calls drop_to_chain(). channel_control.c routes to the new daemon when OPT_SIMPLE_CLOSE is negotiated. peer_control.c is updated so drop_to_chain_simple_close() watches the funding spend and resolves the close RPC without broadcasting the commitment tx, preventing RBF replacement of the mutual close tx, and resend_closing_transactions() uses the same path on restart. The tests in tests/test_closing.py have their @pytest.mark.xfail(strict=True) decorators removed.
Changed components
lightningd/channel_control.clightningd/peer_control.clightningd/simple_close_control.clightningd/simple_close_control.htests/test_closing.pyclosingd/simpleclosed subdaemon integrationInspect captured patch +332 / −5
diff --git a/lightningd/.gitignore b/lightningd/.gitignore
index b2bb5c95..c39af0bd 100644
--- a/lightningd/.gitignore
+++ b/lightningd/.gitignore
@@ -8,4 +8,5 @@ lightning_gossip_compactd
lightning_hsmd
lightning_onchaind
lightning_openingd
+lightning_simpleclosed
lightning_websocketd
diff --git a/lightningd/Makefile b/lightningd/Makefile
index 2d6950d3..f3ba8c0d 100644
--- a/lightningd/Makefile
+++ b/lightningd/Makefile
@@ -43,6 +43,7 @@ LIGHTNINGD_SRC := \
lightningd/plugin_hook.c \
lightningd/routehint.c \
lightningd/runes.c \
+ lightningd/simple_close_control.c \
lightningd/subd.c \
lightningd/wait.c \
lightningd/watch.c
diff --git a/lightningd/channel_control.c b/lightningd/channel_control.c
index ee707bca..202f0103 100644
--- a/lightningd/channel_control.c
+++ b/lightningd/channel_control.c
@@ -3,7 +3,9 @@
#include <ccan/cast/cast.h>
#include <ccan/tal/str/str.h>
#include <channeld/channeld_wiregen.h>
+#include <closingd/simpleclosed_wiregen.h>
#include <common/daemon.h>
+#include <common/features.h>
#include <common/json_command.h>
#include <common/psbt_open.h>
#include <common/shutdown_scriptpubkey.h>
@@ -22,6 +24,7 @@
#include <lightningd/notification.h>
#include <lightningd/peer_fd.h>
#include <lightningd/peer_htlcs.h>
+#include <lightningd/simple_close_control.h>
#include <unistd.h>
struct stfu_result
@@ -1387,6 +1390,7 @@ static void peer_start_closingd_after_shutdown(struct channel *channel,
const int *fds)
{
struct peer_fd *peer_fd;
+ struct lightningd *ld = channel->peer->ld;
if (!fromwire_channeld_shutdown_complete(msg)) {
channel_internal_error(channel, "bad shutdown_complete: %s",
@@ -1395,6 +1399,21 @@ static void peer_start_closingd_after_shutdown(struct channel *channel,
}
peer_fd = new_peer_fd_arr(msg, fds);
+ /* If both sides negotiated option_simple_close, use the simple close
+ * daemon instead of the legacy iterative fee negotiation daemon. */
+ if (feature_negotiated(ld->our_features,
+ channel->peer->their_features,
+ OPT_SIMPLE_CLOSE)) {
+ peer_start_simpleclosed(channel, peer_fd);
+ if (channel->state == CHANNELD_SHUTTING_DOWN)
+ channel_set_state(channel,
+ CHANNELD_SHUTTING_DOWN,
+ CLOSINGD_SIGEXCHANGE,
+ REASON_UNKNOWN,
+ "Start simpleclosed");
+ return;
+ }
+
/* This sets channel->owner, closes down channeld. */
peer_start_closingd(channel, peer_fd);
diff --git a/lightningd/peer_control.c b/lightningd/peer_control.c
index 8ec1cb79..bfee7c08 100644
--- a/lightningd/peer_control.c
+++ b/lightningd/peer_control.c
@@ -7,6 +7,7 @@
#include <channeld/channeld_wiregen.h>
#include <common/addr.h>
#include <common/channel_id.h>
+#include <common/features.h>
#include <common/htlc_trim.h>
#include <common/initial_commit_tx.h>
#include <common/json_channel_type.h>
diff --git a/lightningd/simple_close_control.c b/lightningd/simple_close_control.c
new file mode 100644
index 00000000..74396346
--- /dev/null
+++ b/lightningd/simple_close_control.c
@@ -0,0 +1,299 @@
+/* Master-side control for the simpleclosed subdaemon (option_simple_close). */
+#include "config.h"
+#include <bitcoin/script.h>
+#include <bitcoin/signature.h>
+#include <ccan/tal/str/str.h>
+#include <closingd/simpleclosed_wiregen.h>
+#include <common/fee_states.h>
+#include <common/shutdown_scriptpubkey.h>
+#include <errno.h>
+#include <hsmd/permissions.h>
+#include <inttypes.h>
+#include <lightningd/chaintopology.h>
+#include <lightningd/channel.h>
+#include <lightningd/channel_control.h>
+#include <lightningd/closing_control.h>
+#include <lightningd/connect_control.h>
+#include <lightningd/feerate.h>
+#include <lightningd/hsm_control.h>
+#include <lightningd/lightningd.h>
+#include <lightningd/peer_control.h>
+#include <lightningd/peer_fd.h>
+#include <lightningd/simple_close_control.h>
+#include <lightningd/subd.h>
+#include <wallet/wallet.h>
+#include <wally_bip32.h>
+
+
+/* Check that tx spends exactly our funding outpoint and every output goes
+ * to a known shutdown script. Returns an error string, or NULL on success. */
+static const char *close_tx_check(const tal_t *ctx,
+ const struct channel *channel,
+ const struct bitcoin_tx *tx)
+{
+ if (tx->wtx->num_inputs != 1)
+ return tal_fmt(ctx, "expected 1 input, got %zu",
+ tx->wtx->num_inputs);
+
+ if (!wally_tx_input_spends(&tx->wtx->inputs[0], &channel->funding))
+ return tal_fmt(ctx, "does not spend funding outpoint %s",
+ fmt_bitcoin_outpoint(ctx, &channel->funding));
+
+ for (size_t i = 0; i < tx->wtx->num_outputs; i++) {
+ const struct wally_tx_output *out = &tx->wtx->outputs[i];
+ /* Elements has an explicit fee output with no script. */
+ if (out->script_len == 0) {
+ if (chainparams->is_elements)
+ continue;
+ return tal_fmt(ctx, "output %zu has no script", i);
+ }
+ const u8 *script = tal_dup_arr(ctx, u8,
+ out->script, out->script_len, 0);
+ if (!scripteq(script, channel->shutdown_scriptpubkey[LOCAL])
+ && !scripteq(script, channel->shutdown_scriptpubkey[REMOTE]))
+ return tal_fmt(ctx,
+ "output %zu goes to unknown script %s",
+ i, tal_hex(ctx, script));
+ }
+ return NULL;
+}
+
+/* Master receives simpleclosed_got_sig: validate remote sig, store mutual
+ * close tx, and reply with txid. drop_to_chain handles broadcast. */
+static void handle_simpleclosed_got_sig(struct channel *channel, const u8 *msg)
+{
+ struct lightningd *ld = channel->peer->ld;
+ struct bitcoin_tx *tx;
+ struct bitcoin_txid txid;
+ struct bitcoin_signature sig;
+ const u8 *funding_wscript;
+
+ if (!fromwire_simpleclosed_got_sig(tmpctx, msg, &tx, &sig)) {
+ channel_internal_error(channel, "bad simpleclosed_got_sig: %s",
+ tal_hex(msg, msg));
+ return;
+ }
+ tx->chainparams = chainparams;
+
+ const char *err = close_tx_check(tmpctx, channel, tx);
+ if (err) {
+ channel_internal_error(channel,
+ "bad simpleclosed_got_sig: %s",
+ err);
+ return;
+ }
+
+ funding_wscript = bitcoin_redeem_2of2(tmpctx,
+ &channel->local_funding_pubkey,
+ &channel->channel_info.remote_fundingkey);
+ if (!check_tx_sig(tx, 0, NULL, funding_wscript,
+ &channel->channel_info.remote_fundingkey, &sig)) {
+ channel_internal_error(channel,
+ "bad simpleclosed_got_sig: invalid sig: %s",
+ tal_hex(msg, msg));
+ return;
+ }
+
+ channel_set_last_tx(channel, tx, &sig);
+ wallet_channel_save(ld->wallet, channel);
+
+ bitcoin_txid(tx, &txid);
+ log_info(channel->log,
+ "Simple close: stored closer tx %s",
+ fmt_bitcoin_txid(tmpctx, &txid));
+
+ subd_send_msg(channel->owner,
+ take(towire_simpleclosed_got_sig_reply(NULL, &txid)));
+}
+
+/* Master receives simpleclosed_closee_broadcast: validate remote sig and
+ * store the mutual close tx. drop_to_chain handles broadcast. */
+static void handle_simpleclosed_closee_broadcast(struct channel *channel,
+ const u8 *msg)
+{
+ struct lightningd *ld = channel->peer->ld;
+ struct bitcoin_tx *tx;
+ struct bitcoin_txid txid;
+ struct bitcoin_signature sig;
+ const u8 *funding_wscript;
+
+ if (!fromwire_simpleclosed_closee_broadcast(tmpctx, msg, &tx, &sig)) {
+ channel_internal_error(channel,
+ "bad simpleclosed_closee_broadcast: %s",
+ tal_hex(msg, msg));
+ return;
+ }
+ tx->chainparams = chainparams;
+
+ const char *err = close_tx_check(tmpctx, channel, tx);
+ if (err) {
+ channel_internal_error(channel,
+ "bad simpleclosed_closee_broadcast: %s",
+ err);
+ return;
+ }
+
+ funding_wscript = bitcoin_redeem_2of2(tmpctx,
+ &channel->local_funding_pubkey,
+ &channel->channel_info.remote_fundingkey);
+ if (!check_tx_sig(tx, 0, NULL, funding_wscript,
+ &channel->channel_info.remote_fundingkey, &sig)) {
+ channel_internal_error(channel,
+ "bad simpleclosed_closee_broadcast: invalid sig: %s",
+ tal_hex(msg, msg));
+ return;
+ }
+
+ channel_set_last_tx(channel, tx, &sig);
+ wallet_channel_save(ld->wallet, channel);
+
+ bitcoin_txid(tx, &txid);
+ log_info(channel->log,
+ "Simple close: stored closee tx %s",
+ fmt_bitcoin_txid(tmpctx, &txid));
+}
+
+static void handle_simpleclosed_complete(struct channel *channel, const u8 *msg)
+{
+ if (!fromwire_simpleclosed_complete(msg)) {
+ channel_internal_error(channel,
+ "bad simpleclosed_complete: %s",
+ tal_hex(msg, msg));
+ return;
+ }
+
+ /* Don't report spurious failure when simpleclosed exits. */
+ channel_set_owner(channel, NULL);
+ channel_set_billboard(channel, false, NULL);
+
+ /* Retransmission only, ignore. */
+ if (channel->state != CLOSINGD_SIGEXCHANGE)
+ return;
+
+ channel_set_state(channel,
+ CLOSINGD_SIGEXCHANGE,
+ CLOSINGD_COMPLETE,
+ REASON_UNKNOWN,
+ "Simple close complete");
+
+ drop_to_chain(channel->peer->ld, channel, true, NULL);
+}
+
+static unsigned int simpleclosed_msg(struct subd *sd, const u8 *msg,
+ const int *fds UNUSED)
+{
+ enum simpleclosed_wire t = fromwire_peektype(msg);
+
+ switch (t) {
+ case WIRE_SIMPLECLOSED_GOT_SIG:
+ handle_simpleclosed_got_sig(sd->channel, msg);
+ return 0;
+ case WIRE_SIMPLECLOSED_CLOSEE_BROADCAST:
+ handle_simpleclosed_closee_broadcast(sd->channel, msg);
+ return 0;
+ case WIRE_SIMPLECLOSED_COMPLETE:
+ handle_simpleclosed_complete(sd->channel, msg);
+ return 0;
+
+ /* Inbound-only (master→daemon) — should not be received here. */
+ case WIRE_SIMPLECLOSED_INIT:
+ case WIRE_SIMPLECLOSED_GOT_SIG_REPLY:
+ break;
+ }
+
+ return 0;
+}
+
+void peer_start_simpleclosed(struct channel *channel, struct peer_fd *peer_fd)
+{
+ u8 *initmsg;
+ u32 feerate_perkw;
+ struct amount_msat their_msat;
+ int hsmfd;
+ struct lightningd *ld = channel->peer->ld;
+ u32 *local_wallet_index = NULL;
+ struct ext_key *local_wallet_ext_key = NULL;
+ u32 index_val;
+ struct ext_key ext_key_val;
+
+ if (!channel->shutdown_scriptpubkey[REMOTE]) {
+ channel_internal_error(channel,
+ "Can't start simpleclosed: no remote script");
+ return;
+ }
+
+ hsmfd = hsm_get_client_fd(ld, &channel->peer->id, channel->dbid,
+ HSM_PERM_SIGN_CLOSING_TX | HSM_PERM_COMMITMENT_POINT);
+ if (hsmfd < 0) {
+ log_broken(channel->log,
+ "Could not get hsm fd for simpleclosed: %s",
+ strerror(errno));
+ force_peer_disconnect(ld, channel->peer,
+ "Failed to get hsm fd for simpleclosed");
+ return;
+ }
+
+ channel_set_owner(channel,
+ new_channel_subd(channel, ld, "lightning_simpleclosed", channel,
+ &channel->peer->id, channel->log, true,
+ simpleclosed_wire_name, simpleclosed_msg, channel_errmsg,
+ channel_set_billboard, take(&peer_fd->fd), take(&hsmfd),
+ NULL));
+
+ if (!channel->owner) {
+ log_broken(channel->log,
+ "Could not subdaemon simpleclosed: %s",
+ strerror(errno));
+ force_peer_disconnect(ld, channel->peer,
+ "Failed to create simpleclosed");
+ return;
+ }
+
+ /* Compute their balance. */
+ if (!amount_sat_sub_msat(&their_msat,
+ channel->funding_sats, channel->our_msat)) {
+ log_broken(channel->log,
+ "our_msat overflow on simple close: %s minus %s",
+ fmt_amount_sat(tmpctx, channel->funding_sats),
+ fmt_amount_msat(tmpctx, channel->our_msat));
+ channel_fail_permanent(channel, REASON_LOCAL,
+ "our_msat overflow on simple close");
+ return;
+ }
+
+ feerate_perkw = mutual_close_feerate(ld->topology);
+ if (!feerate_perkw) {
+ feerate_perkw = get_feerate(channel->fee_states,
+ channel->opener, LOCAL) / 2;
+ if (feerate_perkw < get_feerate_floor(ld->topology))
+ feerate_perkw = get_feerate_floor(ld->topology);
+ }
+
+ /* Wallet key for our output. */
+ if (wallet_can_spend(ld->wallet,
+ channel->shutdown_scriptpubkey[LOCAL],
+ tal_bytelen(channel->shutdown_scriptpubkey[LOCAL]),
+ &index_val, NULL)) {
+ if (bip32_key_from_parent(ld->bip32_base, index_val,
+ BIP32_FLAG_KEY_PUBLIC,
+ &ext_key_val) != WALLY_OK) {
+ channel_internal_error(channel,
+ "Could not derive ext public key");
+ return;
+ }
+ local_wallet_index = &index_val;
+ local_wallet_ext_key = &ext_key_val;
+ }
+
+ initmsg = towire_simpleclosed_init(tmpctx, chainparams, &channel->cid,
+ &channel->funding, channel->funding_sats,
+ &channel->local_funding_pubkey,
+ &channel->channel_info.remote_fundingkey,
+ amount_msat_to_sat_round_down(channel->our_msat),
+ amount_msat_to_sat_round_down(their_msat),
+ channel->our_config.dust_limit, feerate_perkw, local_wallet_index,
+ local_wallet_ext_key, channel->shutdown_scriptpubkey[LOCAL],
+ channel->shutdown_scriptpubkey[REMOTE], channel->opener);
+
+ subd_send_msg(channel->owner, take(initmsg));
+}
diff --git a/lightningd/simple_close_control.h b/lightningd/simple_close_control.h
new file mode 100644
index 00000000..524ac3bc
--- /dev/null
+++ b/lightningd/simple_close_control.h
@@ -0,0 +1,11 @@
+#ifndef LIGHTNING_LIGHTNINGD_SIMPLE_CLOSE_CONTROL_H
+#define LIGHTNING_LIGHTNINGD_SIMPLE_CLOSE_CONTROL_H
+#include "config.h"
+
+struct channel;
+struct peer_fd;
+
+/* Start the simpleclosed subdaemon for option_simple_close negotiation. */
+void peer_start_simpleclosed(struct channel *channel, struct peer_fd *peer_fd);
+
+#endif /* LIGHTNING_LIGHTNINGD_SIMPLE_CLOSE_CONTROL_H */
diff --git a/tests/test_closing.py b/tests/test_closing.py
index 91153004..b1099691 100644
--- a/tests/test_closing.py
+++ b/tests/test_closing.py
@@ -4119,7 +4119,6 @@ def test_closing_cpfp(node_factory, bitcoind):
# ---------------------------------------------------------------------------
-@pytest.mark.xfail(strict=True)
def test_simple_close_basic(node_factory, bitcoind, chainparams):
"""Happy path: both nodes negotiate option_simple_close, fund a channel,
make a payment, then close cooperatively. Each side independently builds
@@ -4152,7 +4151,6 @@ def test_simple_close_basic(node_factory, bitcoind, chainparams):
{o['txid'] for o in l2.rpc.listfunds()['outputs']})
-@pytest.mark.xfail(strict=True)
def test_simple_close_closer_pays_fee(node_factory, bitcoind):
"""The closing node (the closer) pays the on-chain fee; the closee gets
its exact channel balance as an output with no deduction."""
@@ -4206,7 +4204,6 @@ def test_simple_close_closer_pays_fee(node_factory, bitcoind):
)
-@pytest.mark.xfail(strict=True)
def test_simple_close_dust_output_omitted(node_factory, bitcoind):
"""When the closee's output would be below the dust limit it must be
omitted from the closing tx (closer_output_only TLV variant)."""
@@ -4240,7 +4237,6 @@ def test_simple_close_dust_output_omitted(node_factory, bitcoind):
f"tx {txid} has {len(tx['vout'])} outputs; expected 1 (dust omitted)"
-@pytest.mark.xfail(strict=True)
def test_simple_close_restart(node_factory, bitcoind):
"""After a clean restart in CLOSINGD_COMPLETE the stored mutual close tx
must be rebroadcast via resend_closing_transactions, not a commitment tx.
@@ -4291,7 +4287,6 @@ def test_simple_close_restart(node_factory, bitcoind):
wait_for(lambda: confirmed_txid in {o['txid'] for o in l2.rpc.listfunds()['outputs']})
-@pytest.mark.xfail(strict=True)
def test_simple_close_closee_path(node_factory, bitcoind):
"""Each node acts as both closer and closee simultaneously. Verify that
handle_simpleclosed_closee_broadcast runs on both nodes (confirmed by the
Why this scored 37/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.