bwatch: detect reorgs and roll back tip
What changed, and why it matters
This commit adds blockchain reorganization (reorg) handling to Core Lightning's 'bwatch' plugin. A reorg happens when the Bitcoin network temporarily replaces a known block with a different one. Previously, bwatch appears to have blindly trusted each new block's parent hash, which could have left the plugin tracking an invalid or forked chain. The patch detects mismatched parent hashes, rolls back its internal tip one block at a time, and tells another component ('watchman') to do the same. It is a defensive correctness fix rather than an active exploit patch; the commit message notes that notifying users whose transactions were affected is deferred to a later commit.
Treat as a correctness/hardening improvement that should be included in the next maintenance release. Review the follow-up commit that notifies owners of reverted watches, since user-visible notification is the remaining security-relevant gap. Operators running nodes that depend on bwatch should upgrade to obtain reorg-safe tip tracking.
Security signals we found
Adds missing blockchain reorg detection and rollback in a block-tracking plugin
Previously accepted new blocks without verifying parent hash against current tip
Crash-recovery design depends on watchman persisting a stale higher height to retrigger rollback
Deferred user/owner notification of reverted watches to a follow-up commit
No explicit CVE, advisory, or researcher attribution in commit or supplied references
Evidence from the diff
The change extends plugins/bwatch/bwatch.c’s handle_block() to validate block->hdr.prev_hash against bwatch->current_blockhash before accepting a new block. On mismatch, it calls the new bwatch_remove_tip(), which deletes the stale tip from the datastore, shrinks block_history, sets the tip to the previous record, and emits a new ‘revert_block_processed’ JSON-RPC notification to watchman via bwatch_send_revert_block_processed() in bwatch_interface.c. If history is exhausted, current_height and current_blockhash are zeroed so the next poll re-initializes from bitcoind. The notification is fire-and-forget; crash recovery relies on watchman’s persisted height being higher than bwatch’s, which triggers another rollback. The commit does not itself notify wallet owners of reverted watches.
Changed components
plugins/bwatch/bwatch.cplugins/bwatch/bwatch_interface.cplugins/bwatch/bwatch_interface.hbwatch plugin block-history datastorewatchman plugin (consumer of revert_block_processed notifications)Inspect captured patch +114 / −6
diff --git a/plugins/bwatch/bwatch.c b/plugins/bwatch/bwatch.c
index d94328a7..72366ae4 100644
--- a/plugins/bwatch/bwatch.c
+++ b/plugins/bwatch/bwatch.c
@@ -79,31 +79,96 @@ static struct command_result *poll_finished(struct command *cmd)
return timer_complete(cmd);
}
-/* Process one block fetched from bitcoind: update tip, append to history,
- * then persist; once persisted we notify watchman, and the next poll is
- * scheduled from the block_processed ack so we don't race ahead of it. */
+/* Remove tip block on reorg. */
+static void bwatch_remove_tip(struct command *cmd, struct bwatch *bwatch)
+{
+ const struct block_record_wire *newtip;
+ size_t count = tal_count(bwatch->block_history);
+
+ if (count == 0) {
+ plugin_log(bwatch->plugin, LOG_BROKEN,
+ "remove_tip called with no block history!");
+ return;
+ }
+
+ plugin_log(bwatch->plugin, LOG_DBG, "Removing stale block %u: %s",
+ bwatch->current_height,
+ fmt_bitcoin_blkid(tmpctx, &bwatch->current_blockhash));
+
+ /* Delete block from datastore */
+ bwatch_delete_block_from_datastore(cmd, bwatch->current_height);
+
+ /* Remove last block from history */
+ tal_resize(&bwatch->block_history, count - 1);
+
+ /* Move tip back one */
+ newtip = bwatch_last_block(bwatch);
+ if (newtip) {
+ assert(newtip->height == bwatch->current_height - 1);
+ bwatch->current_height = newtip->height;
+ bwatch->current_blockhash = newtip->hash;
+
+ /* Tell watchman the tip rolled back so it persists the new height+hash.
+ * If we crash before the ack, watchman's stale height > bwatch's height
+ * on restart, which naturally retriggers the rollback via getwatchmanheight. */
+ bwatch_send_revert_block_processed(cmd, bwatch->current_height,
+ &bwatch->current_blockhash);
+ } else {
+ /* History exhausted: we've rolled back past everything we stored.
+ * Set current_height to 0 so getwatchmanheight_done can reset it to
+ * watchman_height. Don't notify watchman — it already knows its own
+ * height and we're about to resume from there via sequential polling. */
+ bwatch->current_height = 0;
+ memset(&bwatch->current_blockhash, 0, sizeof(bwatch->current_blockhash));
+ }
+}
+
+/* Process or initialize from a block. */
static struct command_result *handle_block(struct command *cmd,
const char *method UNUSED,
const char *buf,
const jsmntok_t *result,
- ptrint_t *block_height)
+ ptrint_t *block_heightptr)
{
struct bwatch *bwatch = bwatch_of(cmd->plugin);
struct bitcoin_blkid blockhash;
struct bitcoin_block *block;
+ bool is_init = (bwatch->current_height == 0);
+ u32 block_height = ptr2int(block_heightptr);
block = block_from_response(buf, result, &blockhash);
if (!block) {
plugin_log(cmd->plugin, LOG_UNUSUAL,
"Failed to get/parse block %u: '%.*s'",
- (unsigned int)ptr2int(block_height),
+ block_height,
json_tok_full_len(result),
json_tok_full(buf, result));
return poll_finished(cmd);
}
- bwatch->current_height = ptr2int(block_height);
+ if (!is_init) {
+ /* Verify the parent of the new block is our current tip; if
+ * not, we have a reorg. Pop the tip and refetch the block
+ * until we find a common ancestor, then roll forward from
+ * there. Skip when history is empty (rollback exhausted it). */
+ if (tal_count(bwatch->block_history) > 0 &&
+ !bitcoin_blkid_eq(&block->hdr.prev_hash, &bwatch->current_blockhash)) {
+ plugin_log(cmd->plugin, LOG_INFORM,
+ "Reorg detected at block %u: expected parent %s, got %s (fetched block hash: %s)",
+ block_height,
+ fmt_bitcoin_blkid(tmpctx, &bwatch->current_blockhash),
+ fmt_bitcoin_blkid(tmpctx, &block->hdr.prev_hash),
+ fmt_bitcoin_blkid(tmpctx, &blockhash));
+ bwatch_remove_tip(cmd, bwatch);
+ return fetch_block_handle(cmd, bwatch->current_height + 1);
+ }
+ }
+
+ /* Update state */
+ bwatch->current_height = block_height;
bwatch->current_blockhash = blockhash;
+
+ /* Update in-memory history immediately */
bwatch_add_block_to_history(bwatch, bwatch->current_height, &blockhash,
&block->hdr.prev_hash);
diff --git a/plugins/bwatch/bwatch_interface.c b/plugins/bwatch/bwatch_interface.c
index 6b2893ba..623046a5 100644
--- a/plugins/bwatch/bwatch_interface.c
+++ b/plugins/bwatch/bwatch_interface.c
@@ -75,3 +75,38 @@ struct command_result *bwatch_send_block_processed(struct command *cmd)
fmt_bitcoin_blkid(tmpctx, &bwatch->current_blockhash));
return send_outreq(req);
}
+
+/*
+ * ============================================================================
+ * REVERT BLOCK NOTIFICATION
+ * ============================================================================
+ */
+
+/* Generic fire-and-forget ack: aux notifications don't gate the poll, so
+ * we just close the aux command on either success or error. */
+static struct command_result *notify_ack(struct command *cmd,
+ const char *method UNUSED,
+ const char *buf UNUSED,
+ const jsmntok_t *result UNUSED,
+ void *arg UNUSED)
+{
+ return aux_command_done(cmd);
+}
+
+/* Notify watchman that a block was rolled back so it can update and persist
+ * its tip. Fire-and-forget via aux_command — the poll timer doesn't depend
+ * on the ack. Crash safety: if we crash before the ack, watchman's stale
+ * height will be higher than bwatch's on restart, retriggering rollback. */
+void bwatch_send_revert_block_processed(struct command *cmd, u32 new_height,
+ const struct bitcoin_blkid *new_hash)
+{
+ struct command *aux = aux_command(cmd);
+ struct out_req *req;
+
+ req = jsonrpc_request_start(aux, "revert_block_processed",
+ notify_ack, notify_ack, NULL);
+ json_add_u32(req->js, "blockheight", new_height);
+ json_add_string(req->js, "blockhash",
+ fmt_bitcoin_blkid(tmpctx, new_hash));
+ send_outreq(req);
+}
diff --git a/plugins/bwatch/bwatch_interface.h b/plugins/bwatch/bwatch_interface.h
index e3424885..30bd1252 100644
--- a/plugins/bwatch/bwatch_interface.h
+++ b/plugins/bwatch/bwatch_interface.h
@@ -15,4 +15,12 @@
* command so timer_complete fires once watchman has acknowledged. */
struct command_result *bwatch_send_block_processed(struct command *cmd);
+/* Notify watchman that the tip has been rolled back during a reorg, so
+ * watchman can update and persist its own height. Fire-and-forget via
+ * an aux_command — the poll timer doesn't depend on this ack. Crash
+ * safety: if we crash before the ack lands, watchman's stale height will
+ * be higher than bwatch's on restart, which retriggers the rollback. */
+void bwatch_send_revert_block_processed(struct command *cmd, u32 new_height,
+ const struct bitcoin_blkid *new_hash);
+
#endif /* LIGHTNING_PLUGINS_BWATCH_BWATCH_INTERFACE_H */
Why this scored 46/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.