simpleclosed.c: add heuristic to delay our tx broadcast if our amount is less AND our fee is less than our peer's amount and fee in case of a reboot the tx will be broadcast as usual.
What changed, and why it matters
This commit adds a one-hour delay before Core Lightning broadcasts a mutual channel-close transaction when the local node has the smaller payout and proposed a lower fee than its peer. The goal is to let the peer's higher-fee transaction win the race to be mined first, reducing the chance that both nodes broadcast competing close transactions after a restart. It is a protocol-robustness improvement, not a fix for an active exploit.
Treat as a normal feature/robustness patch. Review the timer lifecycle to ensure the delayed broadcast is cancelled or rescheduled correctly if the channel state changes before the hour elapses, and verify that the peer's higher-fee transaction detection cannot be manipulated to indefinitely delay settlement.
Security signals we found
Race-condition mitigation in mutual-close broadcast after reconnect/reboot
Heuristic delay to avoid redundant competing close transactions
New wire message field delay_broadcast
Timer-based deferred on-chain broadcast
No cryptographic, memory-safety, or authorization changes
Evidence from the diff
The patch extends the simple close negotiation flow. closingd/simpleclosed.c now compares the closer’s net output (local_sat - sent_fee) against the peer’s output and the proposed fees. If the closer’s output is smaller and its fee is lower, it sets a delay_broadcast flag in the simpleclosed_complete wire message. lightningd/simple_close_control.c schedules drop_to_chain() one hour later via new_reltimer(), starts watching the funding outpoint, and resolves the close RPC immediately. A regression test confirms the log message and non-delayed peer behavior.
Changed components
closingd/simpleclosed.cclosingd/simpleclosed_wire.csvlightningd/simple_close_control.ctests/test_closing.pyInspect captured patch +110 / −6
diff --git a/closingd/simpleclosed.c b/closingd/simpleclosed.c
index 72a42adb..ab1e8dcc 100644
--- a/closingd/simpleclosed.c
+++ b/closingd/simpleclosed.c
@@ -309,7 +309,8 @@ static struct bitcoin_tx *handle_closing_complete(
struct amount_sat local_sat,
struct amount_sat dust_limit,
const u8 *our_last_script,
- const u8 *msg)
+ const u8 *msg,
+ struct amount_sat *their_fee_out)
{
struct channel_id their_cid = {};
u8 *closer_script, *closee_script;
@@ -331,6 +332,8 @@ static struct bitcoin_tx *handle_closing_complete(
"Bad closing_complete: %s",
tal_hex(tmpctx, msg));
+ *their_fee_out = fee_sat;
+
/* BOLT #2:
* The receiver of `closing_complete` (aka. "the closee"):
* ...
@@ -611,7 +614,7 @@ int main(int argc, char *argv[])
bool got_peer_complete, got_our_sig;
struct tlv_closing_tlvs *sent_tlvs;
u8 *sent_closer_script, *sent_closee_script;
- struct amount_sat sent_fee;
+ struct amount_sat sent_fee, their_fee;
u32 sent_locktime = 0;
subdaemon_setup(argc, argv);
@@ -667,7 +670,8 @@ int main(int argc, char *argv[])
}
handle_closing_complete(&funding, funding_sats,
&local_fundingkey, &remote_fundingkey, local_wallet_index,
- local_wallet_ext_key, local_sat, dust_limit, local_script, msg);
+ local_wallet_ext_key, local_sat, dust_limit, local_script,
+ msg, &their_fee);
got_peer_complete = true;
break;
@@ -761,7 +765,27 @@ int main(int argc, char *argv[])
}
}
- wire_sync_write(REQ_FD, take(towire_simpleclosed_complete(NULL)));
+ /* Decide whether to ask master to delay broadcasting our closer tx.
+ * If our output (closer pays the full fee) is less than the peer's
+ * output AND our proposed fee is lower than theirs, their tx has a
+ * better chance of being mined first — give it an hour head-start.
+ * Skip when closer_amount is zero: our output is dust and will be
+ * omitted regardless, so there is nothing to protect by waiting. */
+ struct amount_sat closer_amount;
+ if (!amount_sat_sub(&closer_amount, local_sat, sent_fee))
+ closer_amount = AMOUNT_SAT(0);
+ bool delay_broadcast = amount_sat_greater(closer_amount, AMOUNT_SAT(0))
+ && amount_sat_less(closer_amount, remote_sat)
+ && amount_sat_less(sent_fee, their_fee);
+ if (delay_broadcast)
+ status_debug("Simple close: requesting delayed broadcast"
+ " (our output %s < peer %s, our fee %s < their fee %s)",
+ fmt_amount_sat(tmpctx, closer_amount),
+ fmt_amount_sat(tmpctx, remote_sat),
+ fmt_amount_sat(tmpctx, sent_fee),
+ fmt_amount_sat(tmpctx, their_fee));
+
+ wire_sync_write(REQ_FD, take(towire_simpleclosed_complete(NULL, delay_broadcast)));
tal_free(ctx);
daemon_shutdown();
}
diff --git a/closingd/simpleclosed_wire.csv b/closingd/simpleclosed_wire.csv
index c32bdabb..d3108e3a 100644
--- a/closingd/simpleclosed_wire.csv
+++ b/closingd/simpleclosed_wire.csv
@@ -47,3 +47,4 @@ msgdata,simpleclosed_closee_broadcast,sig,bitcoin_signature,
# Negotiations complete, exiting.
msgtype,simpleclosed_complete,3004
+msgdata,simpleclosed_complete,delay_broadcast,bool,
diff --git a/lightningd/simple_close_control.c b/lightningd/simple_close_control.c
index 74396346..cd52fef6 100644
--- a/lightningd/simple_close_control.c
+++ b/lightningd/simple_close_control.c
@@ -5,7 +5,9 @@
#include <ccan/tal/str/str.h>
#include <closingd/simpleclosed_wiregen.h>
#include <common/fee_states.h>
+#include <common/memleak.h>
#include <common/shutdown_scriptpubkey.h>
+#include <common/timeout.h>
#include <errno.h>
#include <hsmd/permissions.h>
#include <inttypes.h>
@@ -24,6 +26,10 @@
#include <wallet/wallet.h>
#include <wally_bip32.h>
+/* How long the lower-fee closer delays broadcasting its own tx, giving the
+ * peer's higher-fee tx a head-start to be mined first (see the delay
+ * heuristic in handle_simpleclosed_complete). */
+#define SIMPLE_CLOSE_BROADCAST_DELAY_SECS 3600 /* 1 hour */
/* 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. */
@@ -153,9 +159,17 @@ static void handle_simpleclosed_closee_broadcast(struct channel *channel,
fmt_bitcoin_txid(tmpctx, &txid));
}
+static void delayed_drop_to_chain(struct channel *channel)
+{
+ log_info(channel->log, "Simple close: broadcast delay elapsed, broadcasting closing tx");
+ drop_to_chain(channel->peer->ld, channel, true, NULL);
+}
+
static void handle_simpleclosed_complete(struct channel *channel, const u8 *msg)
{
- if (!fromwire_simpleclosed_complete(msg)) {
+ struct lightningd *ld = channel->peer->ld;
+ bool delay_broadcast;
+ if (!fromwire_simpleclosed_complete(msg, &delay_broadcast)) {
channel_internal_error(channel,
"bad simpleclosed_complete: %s",
tal_hex(msg, msg));
@@ -176,7 +190,24 @@ static void handle_simpleclosed_complete(struct channel *channel, const u8 *msg)
REASON_UNKNOWN,
"Simple close complete");
- drop_to_chain(channel->peer->ld, channel, true, NULL);
+ if (delay_broadcast) {
+ log_info(channel->log,
+ "Simple close: delaying broadcast by 1 hour"
+ " (peer has higher-fee tx)");
+ /* Watch the funding outpoint now so onchaind starts when the
+ * peer's higher-fee tx confirms. Also resolves any pending
+ * `close` RPC immediately rather than blocking for an hour. */
+ channel_watch_funding_out(ld, channel);
+ const struct bitcoin_tx **txs
+ = tal_arr(tmpctx, const struct bitcoin_tx *, 1);
+ txs[0] = channel->last_tx;
+ resolve_close_command(ld, channel, true, txs);
+ notleak(new_reltimer(ld->timers, channel,
+ time_from_sec(SIMPLE_CLOSE_BROADCAST_DELAY_SECS),
+ delayed_drop_to_chain, channel));
+ } else {
+ drop_to_chain(ld, channel, true, NULL);
+ }
}
static unsigned int simpleclosed_msg(struct subd *sd, const u8 *msg,
diff --git a/tests/test_closing.py b/tests/test_closing.py
index b1099691..b846d9f1 100644
--- a/tests/test_closing.py
+++ b/tests/test_closing.py
@@ -4320,6 +4320,54 @@ def test_simple_close_closee_path(node_factory, bitcoind):
wait_for(lambda: confirmed_txid in {o['txid'] for o in l2.rpc.listfunds()['outputs']})
+def test_simple_close_delay_broadcast(node_factory, bitcoind, executor):
+ """When the closer has less output AND proposes a lower fee than the peer,
+ it must log a 1-hour delay and let the peer's higher-fee tx get mined first.
+ The peer (l2) has the reversed conditions and must NOT delay.
+
+ We verify the delay via the log message only — we cannot wait an hour."""
+ # feerates[3] (100-block ECONOMICAL target) drives BOTH mutual_close_feerate
+ # AND the anchor commitment feerate used during channel open. The anchor
+ # path clamps to a floor of 1250 sat/kw, and l2 enforces a minimum of
+ # feerates[3]//2 = 7500//2 = 3750 sat/kw on the proposed commitment rate.
+ # So l1 needs feerates[3] >= 3750 to pass l2's open-channel check, yet
+ # still be strictly lower than l2's 7500 to trigger the delay heuristic.
+ # Setting per-node feerates at startup avoids smoothing: the first poll
+ # copies raw values directly with no exponential smoothing applied.
+ l1_opts = {'experimental-simple-close': None,
+ 'feerates': (7500, 7500, 7500, 3750)}
+ l2_opts = {'experimental-simple-close': None,
+ 'feerates': (7500, 7500, 7500, 7500)}
+ l1, l2 = node_factory.line_graph(2, opts=[l1_opts, l2_opts])
+
+ # Pay 600 000 sat l1 → l2: afterwards l1 ≈ 400 000 sat, l2 ≈ 600 000 sat.
+ l1.pay(l2, 600_000_000)
+ wait_for(lambda: only_one(l2.rpc.listpeerchannels()['channels'])['htlcs'] == [])
+
+ # l1's close RPC returns as soon as the mutual-close tx is stored, even
+ # though the broadcast itself is delayed 1 hour. Run it in a thread so
+ # the test can proceed without blocking.
+ fut = executor.submit(l1.rpc.close, l2.info['id'])
+
+ # l1 as closer: closer_amount < remote_sat (l2 has more)
+ # AND sent_fee (≈3750*weight/1000) < their_fee (≈7500*weight/1000) → delay.
+ l1.daemon.wait_for_log('Simple close: delaying broadcast by 1 hour')
+
+ # l2 as closee: no delay expected.
+ wait_for(lambda: only_one(l2.rpc.listpeerchannels()['channels'])['state']
+ == 'CLOSINGD_COMPLETE')
+ assert not l2.daemon.is_in_log('Simple close: delaying broadcast')
+
+ # l2 broadcasts immediately; wait until its tx is confirmed.
+ # We wait for the broadcast log rather than polling getrawmempool(),
+ # which can miss a just-submitted tx under the rpcproxy timing.
+ l2.daemon.wait_for_log('Broadcasting txid')
+ bitcoind.generate_block(1)
+ l1.daemon.wait_for_log('Resolved FUNDING_TRANSACTION/FUNDING_OUTPUT by MUTUAL_CLOSE')
+ l2.daemon.wait_for_log('Resolved FUNDING_TRANSACTION/FUNDING_OUTPUT by MUTUAL_CLOSE')
+ fut.result(timeout=10)
+
+
def test_simple_close_no_feature_fallback(node_factory, bitcoind, chainparams):
"""Without option_simple_close the nodes must fall back to legacy closingd
(iterative closing_signed fee negotiation) and produce a single mutually-
Why this scored 35/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.