bwatch: send chaininfo to watchman on startup
What changed, and why it matters
This change is a defensive startup fix for the bwatch plugin in Core Lightning. It makes the plugin check Bitcoin's sync status and share it with another component (watchman) before starting normal block polling. It also rolls back the plugin's stored block tip if Bitcoin appears to have a shorter chain at startup. The commit does not appear to fix an active exploit; it is more like adding a safety check to prevent inconsistent state when a node restarts while Bitcoin is still syncing or has reorged.
Treat as a normal reliability/consistency improvement. Reviewers should verify that the rollback loop cannot remove more blocks than intended and that current_height is updated consistently inside bwatch_remove_tip. No urgent security action is indicated from the diff alone.
Security signals we found
Startup state synchronization between bwatch and watchman to avoid acting on stale or unsynced chain data
Rollback of persisted block history when local bitcoind chain is shorter than stored tip, reducing risk of inconsistent state after restart
Non-fatal fallback paths prevent init from stalling if dependencies are not ready
Existing error log downgraded from LOG_BROKEN to LOG_DBG, reflecting that watchman-not-ready is an expected transient condition
Evidence from the diff
The patch adds a startup chaininfo handshake in plugins/bwatch. On init, bwatch now queries bcli for chain name, headercount, blockcount, and IBD state, forwards that to watchman via a new chaininfo RPC, and only then starts the poll loop. If bitcoind’s blockcount is lower than bwatch’s persisted current_height, it repeatedly calls bwatch_remove_tip until heights align. Failure paths are non-fatal: if bcli or watchman is unavailable, the plugin logs and starts polling anyway so init does not stall. A log level for an existing watchman error path was also downgraded from LOG_BROKEN to LOG_DBG. The change exposes bwatch_remove_tip in bwatch.h and adds bwatch_send_chaininfo to the interface.
Changed components
plugins/bwatch/bwatch.cplugins/bwatch/bwatch.hplugins/bwatch/bwatch_interface.cplugins/bwatch/bwatch_interface.hInspect captured patch +132 / −6
diff --git a/plugins/bwatch/bwatch.c b/plugins/bwatch/bwatch.c
index 1b31f98b..9228c758 100644
--- a/plugins/bwatch/bwatch.c
+++ b/plugins/bwatch/bwatch.c
@@ -79,8 +79,8 @@ static struct command_result *poll_finished(struct command *cmd)
return timer_complete(cmd);
}
-/* Remove tip block on reorg. */
-static void bwatch_remove_tip(struct command *cmd, struct bwatch *bwatch)
+/* Remove tip block on reorg */
+void bwatch_remove_tip(struct command *cmd, struct bwatch *bwatch)
{
const struct block_record_wire *newtip;
size_t count = tal_count(bwatch->block_history);
@@ -280,9 +280,10 @@ static const char *init(struct command *cmd,
bwatch_load_block_history(cmd, bwatch);
bwatch_load_watches_from_datastore(cmd, bwatch);
- /* Kick off the chain-poll loop. */
- bwatch->poll_timer = global_timer(cmd->plugin, time_from_sec(0),
- bwatch_poll_chain, NULL);
+ /* Send chaininfo to watchman first; the ack/err callbacks then
+ * kick off the chain-poll loop. */
+ global_timer(cmd->plugin, time_from_sec(0),
+ bwatch_send_chaininfo, NULL);
return NULL;
}
diff --git a/plugins/bwatch/bwatch.h b/plugins/bwatch/bwatch.h
index 7a128ef5..40fcf81e 100644
--- a/plugins/bwatch/bwatch.h
+++ b/plugins/bwatch/bwatch.h
@@ -83,4 +83,9 @@ struct bwatch *bwatch_of(struct plugin *plugin);
* can schedule a poll from their own callbacks. */
struct command_result *bwatch_poll_chain(struct command *cmd, void *unused);
+/* Pop the current tip from in-memory + persisted history. Exposed so the
+ * startup chaininfo path can roll back when bitcoind's chain is shorter
+ * than what we have stored. */
+void bwatch_remove_tip(struct command *cmd, struct bwatch *bwatch);
+
#endif /* LIGHTNING_PLUGINS_BWATCH_BWATCH_H */
diff --git a/plugins/bwatch/bwatch_interface.c b/plugins/bwatch/bwatch_interface.c
index 767fc999..41206a47 100644
--- a/plugins/bwatch/bwatch_interface.c
+++ b/plugins/bwatch/bwatch_interface.c
@@ -145,7 +145,7 @@ static struct command_result *block_processed_err(struct command *cmd,
{
struct bwatch *bwatch = bwatch_of(cmd->plugin);
- plugin_log(cmd->plugin, LOG_BROKEN,
+ plugin_log(cmd->plugin, LOG_DBG,
"block_processed RPC failed (watchman not ready?): %.*s",
json_tok_full_len(result), json_tok_full(buf, result));
@@ -191,3 +191,118 @@ void bwatch_send_revert_block_processed(struct command *cmd, u32 new_height,
fmt_bitcoin_blkid(tmpctx, new_hash));
send_outreq(req);
}
+
+/*
+ * ============================================================================
+ * CHAININFO ON STARTUP
+ *
+ * On init bwatch first asks bcli for chain name / IBD state / current
+ * blockcount, optionally rolls its tip back if bitcoind is shorter than
+ * what we have on disk, and forwards the result to watchman via the
+ * `chaininfo` RPC. Whether watchman acks or errors, we then schedule
+ * the normal chain-poll loop.
+ * ============================================================================
+ */
+
+/* Watchman acked chaininfo: kick off normal polling. */
+static struct command_result *chaininfo_ack(struct command *cmd,
+ const char *method UNUSED,
+ const char *buf UNUSED,
+ const jsmntok_t *result UNUSED,
+ void *unused UNUSED)
+{
+ struct bwatch *bwatch = bwatch_of(cmd->plugin);
+ bwatch->poll_timer = global_timer(cmd->plugin, time_from_sec(0),
+ bwatch_poll_chain, NULL);
+ return timer_complete(cmd);
+}
+
+/* Non-fatal: watchman may not be ready yet; poll anyway. */
+static struct command_result *chaininfo_err(struct command *cmd,
+ const char *method UNUSED,
+ const char *buf,
+ const jsmntok_t *result,
+ void *unused UNUSED)
+{
+ plugin_log(cmd->plugin, LOG_DBG,
+ "chaininfo RPC failed: %.*s",
+ json_tok_full_len(result), json_tok_full(buf, result));
+ return chaininfo_ack(cmd, method, buf, result, unused);
+}
+
+/* Got chain state from bcli: optionally roll back, then forward to watchman. */
+static struct command_result *chaininfo_getchaininfo_done(struct command *cmd,
+ const char *method UNUSED,
+ const char *buf,
+ const jsmntok_t *result,
+ void *unused UNUSED)
+{
+ struct bwatch *bwatch = bwatch_of(cmd->plugin);
+ struct out_req *req;
+ const char *chain;
+ u32 headercount, blockcount;
+ bool ibd;
+ const char *err;
+
+ err = json_scan(tmpctx, buf, result,
+ "{chain:%,headercount:%,blockcount:%,ibd:%}",
+ JSON_SCAN_TAL(tmpctx, json_strdup, &chain),
+ JSON_SCAN(json_to_number, &headercount),
+ JSON_SCAN(json_to_number, &blockcount),
+ JSON_SCAN(json_to_bool, &ibd));
+ if (err) {
+ plugin_log(cmd->plugin, LOG_BROKEN,
+ "getchaininfo parse failed: %s", err);
+ return timer_complete(cmd);
+ }
+
+ /* Startup-only rollback: if bitcoind's chain is shorter than our
+ * stored tip, peel off stale blocks now. During normal polling the
+ * shorter-chain case is handled by hash-mismatch reorg detection
+ * inside handle_block. */
+ if (blockcount < bwatch->current_height) {
+ plugin_log(cmd->plugin, LOG_INFORM,
+ "Startup: chain at %u but bwatch at %u; rolling back",
+ blockcount, bwatch->current_height);
+ while (bwatch->current_height > blockcount
+ && bwatch_last_block(bwatch))
+ bwatch_remove_tip(cmd, bwatch);
+ }
+
+ req = jsonrpc_request_start(cmd, "chaininfo",
+ chaininfo_ack, chaininfo_err, NULL);
+ json_add_string(req->js, "chain", chain);
+ json_add_u32(req->js, "headercount", headercount);
+ json_add_u32(req->js, "blockcount", blockcount);
+ json_add_bool(req->js, "ibd", ibd);
+ return send_outreq(req);
+}
+
+/* bcli unreachable: log and fall back to polling so we don't stall init. */
+static struct command_result *chaininfo_getchaininfo_failed(struct command *cmd,
+ const char *method UNUSED,
+ const char *buf UNUSED,
+ const jsmntok_t *result UNUSED,
+ void *unused UNUSED)
+{
+ struct bwatch *bwatch = bwatch_of(cmd->plugin);
+ plugin_log(cmd->plugin, LOG_BROKEN,
+ "getchaininfo failed during chaininfo init");
+ bwatch->poll_timer = global_timer(cmd->plugin, time_from_sec(0),
+ bwatch_poll_chain, NULL);
+ return timer_complete(cmd);
+}
+
+struct command_result *bwatch_send_chaininfo(struct command *cmd,
+ void *unused UNUSED)
+{
+ struct bwatch *bwatch = bwatch_of(cmd->plugin);
+ struct out_req *req;
+
+ req = jsonrpc_request_start(cmd, "getchaininfo",
+ chaininfo_getchaininfo_done,
+ chaininfo_getchaininfo_failed,
+ NULL);
+ json_add_u32(req->js, "last_height", bwatch->current_height);
+ return send_outreq(req);
+}
diff --git a/plugins/bwatch/bwatch_interface.h b/plugins/bwatch/bwatch_interface.h
index 7cea7c7b..8e946cfe 100644
--- a/plugins/bwatch/bwatch_interface.h
+++ b/plugins/bwatch/bwatch_interface.h
@@ -24,6 +24,11 @@ void bwatch_send_watch_revert(struct command *cmd,
const char *owner,
u32 blockheight);
+/* Send chain name / IBD status / sync info to watchman on startup.
+ * Used as a timer callback from init; the ack/err handlers kick the
+ * normal chain-poll loop afterwards. */
+struct command_result *bwatch_send_chaininfo(struct command *cmd, void *unused);
+
/* Send a block_processed RPC to watchman after a new block has been
* persisted. The next poll is started from the ack callback so we don't
* race ahead of watchman's view of the chain. Chains on the same poll
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.