lightningd: use a blockdepth callback instead of a watch_txid.
What changed, and why it matters
This commit refactors how Core Lightning tracks the confirmation depth of a channel's funding transaction. Instead of watching a specific transaction ID, it now watches the block height at which the funding transaction was found. The change is described by the author as making the code more explicit and better suited to external blockchain watchers. There is no direct evidence in the commit that this fixes a security vulnerability, but it does touch reorganization handling and could affect how the node reacts when a funding transaction is removed from the blockchain.
Treat as a normal code-quality/architectural refactor. Reviewers should verify that the new blockdepth watcher correctly handles all channel states during reorgs and that removing the old txid watch does not miss edge cases (e.g., funding transaction replaced or malleated before lock-in). No urgent security action is indicated by the commit itself.
Security signals we found
Refactors blockchain reorg/confirmation handling for channel funding
Changes reorg callback semantics: now returns DELETE_WATCH and resets channel depth
Removes assertion that watched txid equals current channel funding txid
Adds explicit block-height watcher for funding confirmation depth
Splits funding spend watch from funding discovery watch
Evidence from the diff
The patch replaces the txid-based watch_txid/find_txwatch mechanism for tracking funding confirmation with a block-height-based watch_blockdepth callback. It splits funding observation into three parts: channel_watch_funding (watch the scriptPubKey to discover the funding outpoint), channel_watch_depth (track confirmation depth and reorgs via block height), and channel_watch_funding_out (watch for the funding output being spent). The reorg callback now explicitly resets channel->depth to 0 and returns DELETE_WATCH, and the depth callback no longer handles the depth==0 reorg case internally. A test log message is updated to match the new behavior. The change is architectural and preparatory for external watchers.
Changed components
lightningd/channel_control.clightningd/dual_open_control.clightningd/peer_control.clightningd/peer_control.hlightningd/test/run-invoice-select-inchan.ctests/test_misc.pywallet/test/run-wallet.cInspect captured patch +82 / −71
diff --git a/lightningd/channel_control.c b/lightningd/channel_control.c
index 4a7a9b09..836c944d 100644
--- a/lightningd/channel_control.c
+++ b/lightningd/channel_control.c
@@ -1160,9 +1160,6 @@ static void handle_peer_splice_locked(struct channel *channel, const u8 *msg)
wallet_htlcsigs_confirm_inflight(channel->peer->ld->wallet, channel,
&inflight->funding->outpoint);
- /* Stop watching previous funding tx (could be, for announcement) */
- channel_unwatch_funding(channel->peer->ld, channel);
-
/* Stash prev funding data so we can log it after scid is updated
* (to get the blockheight) */
prev_our_msats = channel->our_msat;
diff --git a/lightningd/dual_open_control.c b/lightningd/dual_open_control.c
index cbd43ca5..bdf0fcf8 100644
--- a/lightningd/dual_open_control.c
+++ b/lightningd/dual_open_control.c
@@ -1957,6 +1957,8 @@ static void handle_channel_locked(struct subd *dualopend,
assert(channel->scid);
assert(channel->remote_channel_ready);
+ log_debug(channel->log, "Lockin complete state %s",
+ channel_state_name(channel));
/* This can happen if we missed their sigs, for some reason */
if (channel->state != DUALOPEND_AWAITING_LOCKIN)
log_debug(channel->log, "Lockin complete, but state %s",
@@ -1975,7 +1977,8 @@ static void handle_channel_locked(struct subd *dualopend,
wallet_channel_clear_inflights(dualopend->ld->wallet, channel);
/* That freed watchers in inflights: now watch funding tx */
- channel_watch_funding(dualopend->ld, channel);
+ channel_watch_depth(dualopend->ld, short_channel_id_blocknum(*channel->scid), channel);
+ channel_watch_funding_out(dualopend->ld, channel);
/* FIXME: LND sigs/update_fee msgs? */
peer_start_channeld(channel, peer_fd, NULL, false);
diff --git a/lightningd/peer_control.c b/lightningd/peer_control.c
index 8e773482..8ae452fd 100644
--- a/lightningd/peer_control.c
+++ b/lightningd/peer_control.c
@@ -2307,8 +2307,16 @@ void update_channel_from_inflight(struct lightningd *ld,
wallet_channel_save(ld->wallet, channel);
}
-static void funding_reorged_cb(struct lightningd *ld, struct channel *channel)
+/* All reorg callback must return DELETE_WATCH; we make this so it's clear that we
+ * won't be called again. */
+static enum watch_result funding_reorged_cb(struct lightningd *ld, struct channel *channel)
{
+ log_unusual(channel->log, "Funding txid %s REORG from depth %u (state %s)",
+ fmt_bitcoin_txid(tmpctx, &channel->funding.txid),
+ channel->depth,
+ channel_state_name(channel));
+ channel->depth = 0;
+
/* That's not entirely unexpected in early states */
switch (channel->state) {
case DUALOPEND_AWAITING_LOCKIN:
@@ -2320,13 +2328,13 @@ static void funding_reorged_cb(struct lightningd *ld, struct channel *channel)
"Bad %s state: %s",
__func__,
channel_state_name(channel));
- return;
+ return DELETE_WATCH;
case CHANNELD_AWAITING_LOCKIN:
/* That's not entirely unexpected in early states */
log_debug(channel->log, "Funding tx %s reorganized out!",
fmt_bitcoin_txid(tmpctx, &channel->funding.txid));
channel_set_scid(channel, NULL);
- return;
+ return DELETE_WATCH;
/* But it's often Bad News in later states */
case CHANNELD_AWAITING_SPLICE:
@@ -2345,7 +2353,7 @@ static void funding_reorged_cb(struct lightningd *ld, struct channel *channel)
if (!channel->owner)
log_info(channel->log, "%s", str);
channel_fail_transient(channel, true, "%s", str);
- return;
+ return DELETE_WATCH;
}
/* fall thru */
case AWAITING_UNILATERAL:
@@ -2361,33 +2369,19 @@ static void funding_reorged_cb(struct lightningd *ld, struct channel *channel)
channel_internal_error(channel,
"Funding transaction has been reorged out in state %s!",
channel_state_name(channel));
+ return DELETE_WATCH;
}
static enum watch_result funding_depth_cb(struct lightningd *ld,
- const struct bitcoin_txid *txid,
- const struct bitcoin_tx *tx,
unsigned int depth,
struct channel *channel)
{
- /* This is stub channel, we don't activate anything! */
- if (channel->scid && is_stub_scid(*channel->scid))
- return DELETE_WATCH;
-
- /* We only use this to watch the current funding tx */
- assert(bitcoin_txid_eq(txid, &channel->funding.txid));
-
channel->depth = depth;
log_debug(channel->log, "Funding tx %s depth %u of %u",
- fmt_bitcoin_txid(tmpctx, txid),
+ fmt_bitcoin_txid(tmpctx, &channel->funding.txid),
depth, channel->minimum_depth);
- /* Reorged out? */
- if (depth == 0) {
- funding_reorged_cb(ld, channel);
- return KEEP_WATCHING;
- }
-
switch (channel->state) {
/* We should not be in the callback! */
case DUALOPEND_AWAITING_LOCKIN:
@@ -2406,7 +2400,7 @@ static enum watch_result funding_depth_cb(struct lightningd *ld,
/* If not awaiting lockin/announce, it doesn't care any more */
log_debug(channel->log,
"Funding tx %s confirmed, but peer in state %s",
- fmt_bitcoin_txid(tmpctx, txid),
+ fmt_bitcoin_txid(tmpctx, &channel->funding.txid),
channel_state_name(channel));
return DELETE_WATCH;
@@ -2420,7 +2414,7 @@ static enum watch_result funding_depth_cb(struct lightningd *ld,
/* Fall thru */
case CHANNELD_NORMAL:
case CHANNELD_AWAITING_SPLICE:
- channeld_tell_depth(channel, txid, depth);
+ channeld_tell_depth(channel, &channel->funding.txid, depth);
if (depth < ANNOUNCE_MIN_DEPTH || depth < channel->minimum_depth)
return KEEP_WATCHING;
@@ -2430,6 +2424,16 @@ static enum watch_result funding_depth_cb(struct lightningd *ld,
abort();
}
+void channel_watch_depth(struct lightningd *ld,
+ u32 blockheight,
+ struct channel *channel)
+{
+ watch_blockdepth(channel, ld->topology, blockheight,
+ funding_depth_cb,
+ funding_reorged_cb,
+ channel);
+}
+
/* We see this tx output spend to the funding address. */
static void channel_funding_found(struct lightningd *ld,
const struct bitcoin_tx *tx,
@@ -2438,7 +2442,10 @@ static void channel_funding_found(struct lightningd *ld,
struct channel *channel)
{
/* Closes channel if it doesn't fit in an scid! */
- depthcb_update_scid(channel, &channel->funding, loc);
+ if (depthcb_update_scid(channel, &channel->funding, loc)) {
+ /* We will almost immediately get called, which is what we want! */
+ channel_watch_depth(ld, loc->blkheight, channel);
+ }
}
static enum watch_result funding_spent(struct channel *channel,
@@ -2484,35 +2491,35 @@ void channel_watch_wrong_funding(struct lightningd *ld, struct channel *channel)
}
}
-/* We need to do this before we change channel funding (for splice), otherwise
- * funding_depth_cb will fail the assertion that it's the current funding tx */
-void channel_unwatch_funding(struct lightningd *ld, struct channel *channel)
+void channel_watch_funding_out(struct lightningd *ld, struct channel *channel)
{
- tal_free(find_txwatch(ld->topology,
- &channel->funding.txid, funding_depth_cb, channel));
+ tal_free(channel->funding_spend_watch);
+ channel->funding_spend_watch = watch_txo(channel, ld->topology, channel,
+ &channel->funding,
+ funding_spent);
}
void channel_watch_funding(struct lightningd *ld, struct channel *channel)
{
- const u8 *funding_wscript = bitcoin_redeem_2of2(tmpctx,
- &channel->local_funding_pubkey,
- &channel->channel_info.remote_fundingkey);
-
log_debug(channel->log, "Watching for funding txid: %s",
- fmt_bitcoin_txid(tmpctx, &channel->funding.txid));
- watch_txid(channel, ld->topology,
- &channel->funding.txid, funding_depth_cb, channel);
- watch_scriptpubkey(channel, ld->topology,
- take(scriptpubkey_p2wsh(NULL, funding_wscript)),
- &channel->funding,
- channel->funding_sats,
- channel_funding_found,
- channel);
+ fmt_bitcoin_txid(tmpctx, &channel->funding.txid));
- tal_free(channel->funding_spend_watch);
- channel->funding_spend_watch = watch_txo(channel, ld->topology, channel,
- &channel->funding,
- funding_spent);
+ /* This is stub channel, we don't watch anything funding. */
+ if (!channel->scid || !is_stub_scid(*channel->scid)) {
+ const u8 *funding_wscript = bitcoin_redeem_2of2(tmpctx,
+ &channel->local_funding_pubkey,
+ &channel->channel_info.remote_fundingkey);
+
+ watch_scriptpubkey(channel, ld->topology,
+ take(scriptpubkey_p2wsh(NULL, funding_wscript)),
+ &channel->funding,
+ channel->funding_sats,
+ channel_funding_found,
+ channel);
+ }
+
+ /* We watch for closing of course. */
+ channel_watch_funding_out(ld, channel);
channel_watch_wrong_funding(ld, channel);
}
diff --git a/lightningd/peer_control.h b/lightningd/peer_control.h
index 70016d6d..91160fc1 100644
--- a/lightningd/peer_control.h
+++ b/lightningd/peer_control.h
@@ -134,9 +134,17 @@ void update_channel_from_inflight(struct lightningd *ld,
const struct channel_inflight *inflight,
bool is_splice);
-void channel_unwatch_funding(struct lightningd *ld, struct channel *channel);
+/* Watch for funding tx. */
void channel_watch_funding(struct lightningd *ld, struct channel *channel);
+/* Watch for spend of funding tx. */
+void channel_watch_funding_out(struct lightningd *ld, struct channel *channel);
+
+/* Watch block that funding tx is in */
+void channel_watch_depth(struct lightningd *ld,
+ u32 blockheight,
+ struct channel *channel);
+
/* If this channel has a "wrong funding" shutdown, watch that too. */
void channel_watch_wrong_funding(struct lightningd *ld, struct channel *channel);
diff --git a/lightningd/test/run-invoice-select-inchan.c b/lightningd/test/run-invoice-select-inchan.c
index 2aff2dfd..a03e4f6c 100644
--- a/lightningd/test/run-invoice-select-inchan.c
+++ b/lightningd/test/run-invoice-select-inchan.c
@@ -263,16 +263,6 @@ struct channel *find_channel_by_id(const struct peer *peer UNNEEDED,
struct plugin *find_plugin_for_command(struct lightningd *ld UNNEEDED,
const char *cmd_name UNNEEDED)
{ fprintf(stderr, "find_plugin_for_command called!\n"); abort(); }
-/* Generated stub for find_txwatch_ */
-struct txwatch *find_txwatch_(struct chain_topology *topo UNNEEDED,
- const struct bitcoin_txid *txid UNNEEDED,
- enum watch_result (*cb)(struct lightningd *ld UNNEEDED,
- const struct bitcoin_txid * UNNEEDED,
- const struct bitcoin_tx * UNNEEDED,
- unsigned int depth UNNEEDED,
- void *arg) UNNEEDED,
- void *arg UNNEEDED)
-{ fprintf(stderr, "find_txwatch_ called!\n"); abort(); }
/* Generated stub for fixup_htlcs_out */
void fixup_htlcs_out(struct lightningd *ld UNNEEDED)
{ fprintf(stderr, "fixup_htlcs_out called!\n"); abort(); }
@@ -727,6 +717,14 @@ void wallet_transaction_add(struct wallet *w UNNEEDED, const struct wally_tx *tx
/* Generated stub for wallet_transaction_height */
u32 wallet_transaction_height(struct wallet *w UNNEEDED, const struct bitcoin_txid *txid UNNEEDED)
{ fprintf(stderr, "wallet_transaction_height called!\n"); abort(); }
+/* Generated stub for watch_blockdepth_ */
+bool watch_blockdepth_(const tal_t *ctx UNNEEDED,
+ struct chain_topology *topo UNNEEDED,
+ u32 blockheight UNNEEDED,
+ enum watch_result (*depthcb)(struct lightningd *ld UNNEEDED, u32 depth UNNEEDED, void *) UNNEEDED,
+ enum watch_result (*reorgcb)(struct lightningd *ld UNNEEDED, void *) UNNEEDED,
+ void *arg UNNEEDED)
+{ fprintf(stderr, "watch_blockdepth_ called!\n"); abort(); }
/* Generated stub for watch_opening_inflight */
void watch_opening_inflight(struct lightningd *ld UNNEEDED,
struct channel_inflight *inflight UNNEEDED)
diff --git a/tests/test_misc.py b/tests/test_misc.py
index 4593f733..e53b283c 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -1477,7 +1477,7 @@ def test_funding_reorg_private(node_factory, bitcoind):
# l2 was running, sees last stale block being removed
l2.daemon.wait_for_logs([r'Removing stale block {}'.format(106),
- r'Got depth change .->{} for .* REORG'.format(0)])
+ r'Funding txid .* REORG from depth 2'])
# New one should replace old.
wait_for(lambda: l2.is_local_channel_active('108x1x0'))
diff --git a/wallet/test/run-wallet.c b/wallet/test/run-wallet.c
index 65553af4..542cffce 100644
--- a/wallet/test/run-wallet.c
+++ b/wallet/test/run-wallet.c
@@ -256,16 +256,6 @@ void fatal(const char *fmt UNNEEDED, ...)
/* Generated stub for fatal_vfmt */
void fatal_vfmt(const char *fmt UNNEEDED, va_list ap UNNEEDED)
{ fprintf(stderr, "fatal_vfmt called!\n"); abort(); }
-/* Generated stub for find_txwatch_ */
-struct txwatch *find_txwatch_(struct chain_topology *topo UNNEEDED,
- const struct bitcoin_txid *txid UNNEEDED,
- enum watch_result (*cb)(struct lightningd *ld UNNEEDED,
- const struct bitcoin_txid * UNNEEDED,
- const struct bitcoin_tx * UNNEEDED,
- unsigned int depth UNNEEDED,
- void *arg) UNNEEDED,
- void *arg UNNEEDED)
-{ fprintf(stderr, "find_txwatch_ called!\n"); abort(); }
/* Generated stub for force_peer_disconnect */
void force_peer_disconnect(struct lightningd *ld UNNEEDED,
const struct peer *peer UNNEEDED,
@@ -769,6 +759,14 @@ u8 *unsigned_node_announcement(const tal_t *ctx UNNEEDED,
struct lightningd *ld UNNEEDED,
const u8 *prev UNNEEDED)
{ fprintf(stderr, "unsigned_node_announcement called!\n"); abort(); }
+/* Generated stub for watch_blockdepth_ */
+bool watch_blockdepth_(const tal_t *ctx UNNEEDED,
+ struct chain_topology *topo UNNEEDED,
+ u32 blockheight UNNEEDED,
+ enum watch_result (*depthcb)(struct lightningd *ld UNNEEDED, u32 depth UNNEEDED, void *) UNNEEDED,
+ enum watch_result (*reorgcb)(struct lightningd *ld UNNEEDED, void *) UNNEEDED,
+ void *arg UNNEEDED)
+{ fprintf(stderr, "watch_blockdepth_ called!\n"); abort(); }
/* Generated stub for watch_opening_inflight */
void watch_opening_inflight(struct lightningd *ld UNNEEDED,
struct channel_inflight *inflight UNNEEDED)
Why this scored 23/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.