lightningd: add generic scriptpubkey watches.
What changed, and why it matters
This commit adds a new internal mechanism for Core Lightning to watch for Bitcoin transactions that pay to specific addresses (scriptpubkeys). It is a feature/refactoring change intended to support future funding and splicing workflows. There is no direct evidence in the commit that it fixes an active security bug, but it changes how the node detects incoming funds and includes a small logging improvement to help diagnose mismatched transactions.
Treat as a normal feature/refactoring commit. Reviewers should verify that watch_check_tx_outputs correctly handles reorgs, duplicate scriptpubkey watches, and asset types other than mainchain Bitcoin, and that the new callbacks cannot be triggered by adversarially crafted transactions that partially match the watch criteria.
Security signals we found
New callback-driven watch subsystem added to chain topology
Exact-match validation on scriptpubkey, txid, outnum, and amount before invoking callback
Logging added for mismatched txid, amount, or output number referencing GitHub issue #8892
Hash table iteration is locked to prevent re-entrant inserts during callbacks
No removal of existing txid/txo watches; functionality is additive
Evidence from the diff
The patch introduces a generic scriptpubkey-based watch subsystem in lightningd/watch.c and registers it in chain_topology. A new hash table (scriptpubkeywatches) stores watches keyed by scriptpubkey; each watch records an expected outpoint, expected amount, and a callback. During block filtering (filter_block_txs), the node now calls watch_check_tx_outputs() before the existing txid/broadcast checks, invoking callbacks when a transaction output exactly matches the expected scriptpubkey, txid, output index, and amount. Mismatches are logged as unusual events. The change also includes a stray whitespace addition in channel.c and minor loop-variable refactoring.
Changed components
lightningd/chaintopology.clightningd/chaintopology.hlightningd/watch.clightningd/watch.hlightningd/channel.cInspect captured patch +225 / −6
diff --git a/lightningd/chaintopology.c b/lightningd/chaintopology.c
index 17880818..9e9ea0fc 100644
--- a/lightningd/chaintopology.c
+++ b/lightningd/chaintopology.c
@@ -40,19 +40,17 @@ static bool we_broadcast(const struct chain_topology *topo,
static void filter_block_txs(struct chain_topology *topo, struct block *b)
{
- size_t i;
-
/* Now we see if any of those txs are interesting. */
const size_t num_txs = tal_count(b->full_txs);
- for (i = 0; i < num_txs; i++) {
+ for (size_t i = 0; i < num_txs; i++) {
struct bitcoin_tx *tx = b->full_txs[i];
struct bitcoin_txid txid;
- size_t j;
+ const struct txlocator loc = { b->height, i };
bool is_coinbase = i == 0;
size_t *our_outnums;
/* Tell them if it spends a txo we care about. */
- for (j = 0; j < tx->wtx->num_inputs; j++) {
+ for (size_t j = 0; j < tx->wtx->num_inputs; j++) {
struct bitcoin_outpoint out;
struct txowatch_hash_iter it;
@@ -89,7 +87,11 @@ static void filter_block_txs(struct chain_topology *topo, struct block *b)
}
/* We did spends first, in case that tells us to watch tx. */
- if (watching_txid(topo, &txid) || we_broadcast(topo, &txid)) {
+
+ /* Make sure we preserve any transaction we are interested in */
+ if (watch_check_tx_outputs(topo, &loc, tx, &txid)
+ || watching_txid(topo, &txid)
+ || we_broadcast(topo, &txid)) {
wallet_transaction_add(topo->ld->wallet,
tx->wtx, b->height, i);
}
@@ -1233,6 +1235,7 @@ struct chain_topology *new_topology(struct lightningd *ld, struct logger *log)
topo->outgoing_txs = new_htable(topo, outgoing_tx_map);
topo->txwatches = new_htable(topo, txwatch_hash);
topo->txowatches = new_htable(topo, txowatch_hash);
+ topo->scriptpubkeywatches = new_htable(topo, scriptpubkeywatch_hash);
topo->log = log;
topo->bitcoind = new_bitcoind(topo, ld, log);
topo->poll_seconds = 30;
diff --git a/lightningd/chaintopology.h b/lightningd/chaintopology.h
index 8515cb5b..e9721775 100644
--- a/lightningd/chaintopology.h
+++ b/lightningd/chaintopology.h
@@ -9,6 +9,7 @@ struct command;
struct lightningd;
struct peer;
struct txwatch;
+struct scriptpubkeywatch;
struct wallet;
/* We keep the last three in case there are outliers (for min/max) */
@@ -139,6 +140,7 @@ struct chain_topology {
/* Transactions/txos we are watching. */
struct txwatch_hash *txwatches;
struct txowatch_hash *txowatches;
+ struct scriptpubkeywatch_hash *scriptpubkeywatches;
/* The number of headers known to the bitcoin backend at startup. Not
* updated after the initial check. */
diff --git a/lightningd/channel.c b/lightningd/channel.c
index 2d4167d7..fa22c40a 100644
--- a/lightningd/channel.c
+++ b/lightningd/channel.c
@@ -92,6 +92,7 @@ void delete_channel(struct channel *channel STEALS,
struct peer *peer = channel->peer;
struct lightningd *ld = peer->ld;
+
if (channel->dbid != 0) {
wallet_channel_close(ld->wallet, channel);
/* Never open at all, not ours. */
diff --git a/lightningd/watch.c b/lightningd/watch.c
index 0731f711..93712fcf 100644
--- a/lightningd/watch.c
+++ b/lightningd/watch.c
@@ -31,6 +31,8 @@
*/
#include "config.h"
#include <bitcoin/psbt.h>
+#include <ccan/tal/str/str.h>
+#include <common/addr.h>
#include <lightningd/chaintopology.h>
#include <lightningd/channel.h>
#include <lightningd/lightningd.h>
@@ -316,3 +318,154 @@ void txwatch_inform(const struct chain_topology *topo,
if (taken(tx))
tal_free(tx);
}
+
+struct scriptpubkeywatch {
+ struct script_with_len swl;
+ struct bitcoin_outpoint expected_outpoint;
+ struct amount_sat expected_amount;
+ void (*cb)(struct lightningd *ld,
+ const struct bitcoin_tx *tx,
+ u32 outnum,
+ const struct txlocator *loc,
+ void *);
+ void *arg;
+};
+
+const struct script_with_len *scriptpubkeywatch_keyof(const struct scriptpubkeywatch *w)
+{
+ return &w->swl;
+}
+
+bool scriptpubkeywatch_eq(const struct scriptpubkeywatch *w, const struct script_with_len *swl)
+{
+ return script_with_len_eq(&w->swl, swl);
+}
+
+static void destroy_scriptpubkeywatch(struct scriptpubkeywatch *w, struct chain_topology *topo)
+{
+ scriptpubkeywatch_hash_del(topo->scriptpubkeywatches, w);
+}
+
+void watch_scriptpubkey_(const tal_t *ctx,
+ struct chain_topology *topo,
+ const u8 *scriptpubkey TAKES,
+ const struct bitcoin_outpoint *expected_outpoint,
+ struct amount_sat expected_amount,
+ void (*cb)(struct lightningd *ld,
+ const struct bitcoin_tx *tx,
+ u32 outnum,
+ const struct txlocator *loc,
+ void *),
+ void *arg)
+{
+ struct scriptpubkeywatch *w = tal(ctx, struct scriptpubkeywatch);
+ w->swl.script = tal_dup_talarr(w, u8, scriptpubkey);
+ w->swl.len = tal_bytelen(w->swl.script);
+ w->expected_outpoint = *expected_outpoint;
+ w->expected_amount = expected_amount;
+ w->cb = cb;
+ w->arg = arg;
+ scriptpubkeywatch_hash_add(topo->scriptpubkeywatches, w);
+ tal_add_destructor2(w, destroy_scriptpubkeywatch, topo);
+}
+
+bool unwatch_scriptpubkey_(const tal_t *ctx,
+ struct chain_topology *topo,
+ const u8 *scriptpubkey TAKES,
+ const struct bitcoin_outpoint *expected_outpoint,
+ struct amount_sat expected_amount,
+ void (*cb)(struct lightningd *ld,
+ const struct bitcoin_tx *tx,
+ u32 outnum,
+ const struct txlocator *loc,
+ void *),
+ void *arg)
+{
+ struct scriptpubkeywatch_hash_iter it;
+ const struct script_with_len swl = { scriptpubkey, tal_bytelen(scriptpubkey) };
+
+ for (struct scriptpubkeywatch *w = scriptpubkeywatch_hash_getfirst(topo->scriptpubkeywatches, &swl, &it);
+ w;
+ w = scriptpubkeywatch_hash_getnext(topo->scriptpubkeywatches, &swl, &it)) {
+ if (!bitcoin_outpoint_eq(&w->expected_outpoint, expected_outpoint)
+ || !amount_sat_eq(w->expected_amount, expected_amount)
+ || w->cb != cb
+ || w->arg != arg) {
+ continue;
+ }
+ tal_free(w);
+ return true;
+ }
+ return false;
+}
+
+bool watch_check_tx_outputs(const struct chain_topology *topo,
+ const struct txlocator *loc,
+ const struct bitcoin_tx *tx,
+ const struct bitcoin_txid *txid)
+{
+ bool tx_interesting = false;
+
+ for (size_t outnum = 0; outnum < tx->wtx->num_outputs; outnum++) {
+ const struct wally_tx_output *txout = &tx->wtx->outputs[outnum];
+ const struct script_with_len swl = { txout->script, txout->script_len };
+ struct scriptpubkeywatch_hash_iter it;
+ bool output_matched = false, bad_txid = false, bad_amount = false, bad_outnum = false;
+ struct amount_asset outasset = bitcoin_tx_output_get_amount(tx, outnum);
+
+ /* Ensure callbacks don't do an insert during iteration! */
+ scriptpubkeywatch_hash_lock(topo->scriptpubkeywatches);
+ for (struct scriptpubkeywatch *w = scriptpubkeywatch_hash_getfirst(topo->scriptpubkeywatches, &swl, &it);
+ w;
+ w = scriptpubkeywatch_hash_getnext(topo->scriptpubkeywatches, &swl, &it)) {
+ if (!bitcoin_txid_eq(&w->expected_outpoint.txid, txid)) {
+ bad_txid = true;
+ continue;
+ }
+ if (outnum != w->expected_outpoint.n) {
+ bad_outnum = true;
+ continue;
+ }
+ if (!amount_asset_is_main(&outasset)
+ || !amount_sat_eq(amount_asset_to_sat(&outasset), w->expected_amount)) {
+ bad_amount = true;
+ continue;
+ }
+
+ w->cb(topo->ld, tx, outnum, loc, w->arg);
+ output_matched = true;
+ tx_interesting = true;
+ }
+ scriptpubkeywatch_hash_unlock(topo->scriptpubkeywatches);
+
+ /* Only complain about mismatch if we missed all of them.
+ * This helps diagnose mistakes like wrong txid, see
+ * https://github.com/ElementsProject/lightning/issues/8892 */
+ if (!output_matched && (bad_txid || bad_amount || bad_outnum)) {
+ const char *addr = encode_scriptpubkey_to_addr(tmpctx, chainparams,
+ txout->script, txout->script_len);
+ if (!addr)
+ addr = tal_fmt(tmpctx, "Scriptpubkey %s", tal_hexstr(tmpctx, txout->script, txout->script_len));
+ if (bad_txid) {
+ log_unusual(topo->ld->log,
+ "Unexpected spend to %s by unexpected txid %s:%zu",
+ addr, fmt_bitcoin_txid(tmpctx, txid), outnum);
+ }
+ if (bad_amount) {
+ log_unusual(topo->ld->log,
+ "Unexpected amount %s to %s by txid %s:%zu",
+ amount_asset_is_main(&outasset)
+ ? fmt_amount_sat(tmpctx, amount_asset_to_sat(&outasset))
+ : "fee output",
+ addr, fmt_bitcoin_txid(tmpctx, txid), outnum);
+ }
+ if (bad_outnum) {
+ log_unusual(topo->ld->log,
+ "Unexpected output number %zu paying to %s in txid %s",
+ outnum, addr, fmt_bitcoin_txid(tmpctx, txid));
+ }
+ }
+ }
+
+ return tx_interesting;
+}
diff --git a/lightningd/watch.h b/lightningd/watch.h
index 596da3a3..57ceb149 100644
--- a/lightningd/watch.h
+++ b/lightningd/watch.h
@@ -1,6 +1,7 @@
#ifndef LIGHTNING_LIGHTNINGD_WATCH_H
#define LIGHTNING_LIGHTNINGD_WATCH_H
#include "config.h"
+#include <bitcoin/script.h>
#include <bitcoin/tx.h>
#include <ccan/htable/htable_type.h>
@@ -8,8 +9,10 @@ struct block;
struct channel;
struct chain_topology;
struct lightningd;
+struct txlocator;
struct txowatch;
struct txwatch;
+struct scriptpubkeywatch;
enum watch_result {
DELETE_WATCH = -1,
@@ -29,6 +32,10 @@ bool txwatch_eq(const struct txwatch *w, const struct bitcoin_txid *txid);
HTABLE_DEFINE_DUPS_TYPE(struct txwatch, txwatch_keyof, txid_hash, txwatch_eq,
txwatch_hash);
+const struct script_with_len *scriptpubkeywatch_keyof(const struct scriptpubkeywatch *w);
+bool scriptpubkeywatch_eq(const struct scriptpubkeywatch *w, const struct script_with_len *swl);
+HTABLE_DEFINE_DUPS_TYPE(struct scriptpubkeywatch, scriptpubkeywatch_keyof, script_with_len_hash, scriptpubkeywatch_eq,
+ scriptpubkeywatch_hash);
struct txwatch *watch_txid_(const tal_t *ctx,
struct chain_topology *topo,
@@ -93,5 +100,58 @@ void txwatch_inform(const struct chain_topology *topo,
const struct bitcoin_txid *txid,
struct bitcoin_tx *tx TAKES);
+/* Watch for specific spends to this scriptpubkey */
+void watch_scriptpubkey_(const tal_t *ctx,
+ struct chain_topology *topo,
+ const u8 *scriptpubkey TAKES,
+ const struct bitcoin_outpoint *expected_outpoint,
+ struct amount_sat expected_amount,
+ void (*cb)(struct lightningd *ld,
+ const struct bitcoin_tx *tx,
+ u32 outnum,
+ const struct txlocator *loc,
+ void *),
+ void *arg);
+
+#define watch_scriptpubkey(ctx, topo, scriptpubkey, expected_outpoint, expected_amount, cb, arg) \
+ watch_scriptpubkey_((ctx), (topo), (scriptpubkey), \
+ (expected_outpoint), (expected_amount), \
+ typesafe_cb_preargs(void, void *, \
+ (cb), (arg), \
+ struct lightningd *, \
+ const struct bitcoin_tx *, \
+ u32 outnum, \
+ const struct txlocator *), \
+ (arg))
+
+bool unwatch_scriptpubkey_(const tal_t *ctx,
+ struct chain_topology *topo,
+ const u8 *scriptpubkey TAKES,
+ const struct bitcoin_outpoint *expected_outpoint,
+ struct amount_sat expected_amount,
+ void (*cb)(struct lightningd *ld,
+ const struct bitcoin_tx *tx,
+ u32 outnum,
+ const struct txlocator *loc,
+ void *),
+ void *arg);
+
+#define unwatch_scriptpubkey(ctx, topo, scriptpubkey, expected_outpoint, expected_amount, cb, arg) \
+ unwatch_scriptpubkey_((ctx), (topo), (scriptpubkey), \
+ (expected_outpoint), (expected_amount), \
+ typesafe_cb_preargs(void, void *, \
+ (cb), (arg), \
+ struct lightningd *, \
+ const struct bitcoin_tx *, \
+ u32 outnum, \
+ const struct txlocator *), \
+ (arg))
+
+/* Call any scriptpubkey callbacks for this tx */
+bool watch_check_tx_outputs(const struct chain_topology *topo,
+ const struct txlocator *loc,
+ const struct bitcoin_tx *tx,
+ const struct bitcoin_txid *txid);
+
void watch_topology_changed(struct chain_topology *topo);
#endif /* LIGHTNING_LIGHTNINGD_WATCH_H */
Why this scored 26/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.