tools/lightning-downgrade: tool to downgrade (offline) v25.12 to v25.09.
What changed, and why it matters
This commit adds a new offline command-line tool, lightning-downgrade, that lets a user roll back a Core Lightning node's database from the upcoming v25.12 release to the previous v25.09 release. It is meant as a recovery aid if an upgrade goes wrong. The tool only works when the lightning node is stopped, checks for a running process, and reverses database migrations one by one using built-in revert SQL and functions. It also deletes an incompatible gossip store file after downgrading. There is no direct evidence in the commit that this fixes an active security bug; it is a new operational utility.
Treat this as a new operational tool rather than a security patch. Review the revert SQL and revertfn implementations in wallet/migrations for correctness, ensure the tool cannot be invoked by a running daemon, and consider documenting that users must back up their lightning-dir before attempting a downgrade. Run the new test_downgrade.py in CI with PREV_LIGHTNINGD set to a v25.09 binary.
Security signals we found
New database schema mutation utility with revert SQL/function paths
Refuses to run when lightningd PID file is present, mitigating live-node downgrade risk
Deletes gossip_store when target version is marked incompatible
Adds downgrade-specific functional test including payment flow after downgrade/upgrade cycle
Evidence from the diff
The patch introduces tools/lightning-downgrade.c, a standalone offline utility that opens the node’s SQLite wallet, verifies the current DB schema version, refuses to run if lightningd appears to be running (via PID file check), and then iterates downward from the current version to the v25.09 schema height (276) applying each migration’s revertsql and revertfn. It updates the version table, commits, and removes gossip_store when the target version is not gossip-compatible. The Makefile is updated to build the tool, and a Python test is added that exercises the tool, optionally runs the previous lightningd binary, and then upgrades again. The code is defensive (no downgrade of a live node, version bounds checks, fatal DB error handling), but because it mutates the wallet schema and deletes gossip data, misuse or bugs in revert logic could cause data loss or an inconsistent node state.
Changed components
tools/lightning-downgrade.ctools/MakefileMakefilewallet/Makefiletests/test_downgrade.pyInspect captured patch +348 / −2
diff --git a/Makefile b/Makefile
index 58d0ae5..d48b75b 100644
--- a/Makefile
+++ b/Makefile
@@ -9,6 +9,9 @@ $(info Building version $(VERSION))
# Next release.
CLN_NEXT_VERSION := v25.12
+# Previous release (for downgrade testing)
+CLN_PREV_VERSION := v25.09
+
# --quiet / -s means quiet, dammit!
ifeq ($(findstring s,$(word 1, $(MAKEFLAGS))),s)
ECHO := :
diff --git a/tests/test_downgrade.py b/tests/test_downgrade.py
new file mode 100644
index 0000000..9ac82ac
--- /dev/null
+++ b/tests/test_downgrade.py
@@ -0,0 +1,65 @@
+from fixtures import * # noqa: F401,F403
+from utils import (
+ TIMEOUT # noqa: F401
+)
+
+import os
+import subprocess
+
+
+def test_downgrade(node_factory, executor):
+ l1, l2 = node_factory.line_graph(2, opts={'may_reconnect': True})
+
+ # From the binary:
+ # ERROR_DBVERSION = 1
+ # ERROR_DBFAIL = 2
+ ERROR_USAGE = 3
+ # ERROR_INTERNAL = 99
+
+ # lightning-downgrade understands a subset of the options
+ # to lightningd.
+ downgrade_opts = []
+ for o in l1.daemon.opts:
+ if o in ('network', 'lightning-dir', 'conf', 'rpc-file', 'wallet'):
+ if l1.daemon.opts[o] is None:
+ downgrade_opts.append(f"--{o}")
+ else:
+ downgrade_opts.append(f"--{o}={l1.daemon.opts[o]}")
+
+ cmd_line = ["tools/lightning-downgrade"] + downgrade_opts
+ if os.getenv("VALGRIND") == "1":
+ cmd_line = ['valgrind', '-q', '--error-exitcode=7'] + cmd_line
+
+ # No downgrade on live nodes!
+ retcode = subprocess.call(cmd_line, timeout=TIMEOUT)
+ assert retcode == ERROR_USAGE
+
+ l1.stop()
+ subprocess.check_call(cmd_line)
+
+ # Test with old lightningd if it's available.
+ old_cln = os.getenv('PREV_LIGHTNINGD')
+ if old_cln:
+ current_executable = l1.daemon.executable
+ l1.daemon.executable = old_cln
+
+ l1.start()
+
+ # It should connect to l2 no problems, make payment.
+ l1.connect(l2)
+ inv = l2.rpc.invoice(1000, 'test_downgrade', 'test_downgrade')
+ l1.rpc.xpay(inv['bolt11'])
+ l1.stop()
+ l1.daemon.executable = current_executable
+
+ # Another downgrade is a noop.
+ assert subprocess.check_output(cmd_line).decode("utf8").startswith("Already compatible with ")
+
+ # Should be able to upgrade without any trouble
+ l1.daemon.opts['database-upgrade'] = True
+ l1.start()
+ assert l1.daemon.is_in_log("Updating database from version")
+
+ l1.connect(l2)
+ inv2 = l2.rpc.invoice(1000, 'test_downgrade2', 'test_downgrade2')
+ l1.rpc.xpay(inv2['bolt11'])
diff --git a/tools/Makefile b/tools/Makefile
index df03ada..dbb5c0e 100644
--- a/tools/Makefile
+++ b/tools/Makefile
@@ -1,5 +1,5 @@
#! /usr/bin/make
-TOOLS := tools/lightning-hsmtool
+TOOLS := tools/lightning-hsmtool tools/lightning-downgrade
TOOLS_SRC := $(TOOLS:=.c)
TOOLS_OBJ := $(TOOLS_SRC:.c=.o)
@@ -13,8 +13,22 @@ ALL_PROGRAMS += $(TOOLS)
tools/headerversions: $(FORCE) tools/headerversions.o libccan.a
@trap "rm -f $@.tmp.$$$$" EXIT; $(LINK.o) tools/headerversions.o libccan.a $(LOADLIBES) $(LDLIBS) -o $@.tmp.$$$$ && mv -f $@.tmp.$$$$ $@
+$(TOOLS): libcommon.a
+
tools/headerversions.o: ccan/config.h
-tools/lightning-hsmtool: tools/lightning-hsmtool.o libcommon.a
+tools/lightning-hsmtool: tools/lightning-hsmtool.o
+tools/lightning-downgrade.o: CFLAGS:=$(CFLAGS) -DCLN_PREV_VERSION=$(CLN_PREV_VERSION)
+
+tools/lightning-downgrade: \
+ db/exec.o \
+ db/bindings.o \
+ db/utils.o \
+ wallet/migrations.o \
+ $(DB_OBJS) \
+ $(WALLET_DB_QUERIES:.c=.o) \
+ tools/lightning-downgrade.o
+
+update-mocks: $(tools/lightning-downgrade.c:%=update-mocks/%.c)
clean: tools-clean
diff --git a/tools/lightning-downgrade.c b/tools/lightning-downgrade.c
new file mode 100644
index 0000000..5dce80f
--- /dev/null
+++ b/tools/lightning-downgrade.c
@@ -0,0 +1,262 @@
+/* Tool for limited downgrade of an offline node */
+#include "config.h"
+#include <bitcoin/chainparams.h>
+#include <ccan/array_size/array_size.h>
+#include <ccan/err/err.h>
+#include <ccan/opt/opt.h>
+#include <ccan/tal/path/path.h>
+#include <ccan/tal/str/str.h>
+#include <common/configdir.h>
+#include <common/utils.h>
+#include <db/bindings.h>
+#include <db/common.h>
+#include <db/exec.h>
+#include <db/utils.h>
+#include <stdio.h>
+#include <unistd.h>
+#include <wallet/migrations.h>
+
+#define ERROR_DBVERSION 1
+#define ERROR_DBFAIL 2
+#define ERROR_USAGE 3
+#define ERROR_INTERNAL 99
+
+#define PREV_VERSION stringify(CLN_PREV_VERSION)
+
+struct db_version {
+ const char *name;
+ size_t db_height;
+ bool gossip_store_compatible;
+};
+
+static const struct db_version db_versions[] = {
+ { "v25.09", 276, false },
+ /* When we implement v25.12 downgrade: { "v25.12", 280, ???}, */
+};
+
+static const struct db_version *version_db(const char *version)
+{
+ for (size_t i = 0; i < ARRAY_SIZE(db_versions); i++) {
+ if (streq(db_versions[i].name, version))
+ return &db_versions[i];
+ }
+ errx(ERROR_INTERNAL, "Unknown version %s", version);
+}
+
+static void db_error(void *unused, bool fatal, const char *fmt, va_list ap)
+{
+ vfprintf(stderr, fmt, ap);
+ fprintf(stderr, "\n");
+ if (fatal)
+ exit(ERROR_DBFAIL);
+}
+
+/* The standard opt_log_stderr_exit exits with status 1 */
+static void opt_log_stderr_exit_usage(const char *fmt, ...)
+{
+ va_list ap;
+
+ va_start(ap, fmt);
+ vfprintf(stderr, fmt, ap);
+ fprintf(stderr, "\n");
+ va_end(ap);
+ exit(ERROR_USAGE);
+}
+
+int main(int argc, char *argv[])
+{
+ char *config_filename, *base_dir, *net_dir, *rpc_filename, *wallet_dsn = NULL;
+ size_t current, prev_version_height, num_migrations;
+ struct db *db;
+ const struct db_migration *migrations;
+ struct db_stmt *stmt;
+
+ setup_locale();
+ err_set_progname(argv[0]);
+
+ minimal_config_opts(tmpctx, argc, argv, &config_filename, &base_dir,
+ &net_dir, &rpc_filename);
+ opt_register_early_arg("--wallet", opt_set_talstr, NULL,
+ &wallet_dsn,
+ "Location of the wallet database.");
+ opt_register_noarg("--help|-h", opt_usage_and_exit,
+ "A tool to downgrade an offline Core Lightning Node to " PREV_VERSION,
+ "Print this message.");
+ opt_early_parse(argc, argv, opt_log_stderr_exit_usage);
+ opt_parse(&argc, argv, opt_log_stderr_exit_usage);
+
+ if (argc != 1)
+ opt_usage_exit_fail("No arguments expected");
+
+ if (!wallet_dsn)
+ wallet_dsn = tal_fmt(tmpctx, "sqlite3://%s/lightningd.sqlite3", net_dir);
+
+ if (path_is_file(path_join(tmpctx, base_dir,
+ tal_fmt(tmpctx, "lightningd-%s.pid",
+ chainparams->network_name)))) {
+ errx(ERROR_USAGE,
+ "Lightningd PID file exists, aborting: lightningd must not be running");
+ }
+
+ migrations = get_db_migrations(&num_migrations);
+ prev_version_height = version_db(PREV_VERSION)->db_height;
+
+ /* Open db, check it's the expected version */
+ db = db_open(tmpctx, wallet_dsn, false, false, db_error, NULL);
+ if (!db)
+ err(1, "Could not open database %s", wallet_dsn);
+ db->report_changes_fn = NULL;
+
+ db_begin_transaction(db);
+ db->data_version = db_data_version_get(db);
+ current = db_get_version(db);
+
+ if (current < prev_version_height)
+ errx(ERROR_DBVERSION, "Database version %zu already less than %zu expected for %s",
+ current, prev_version_height, PREV_VERSION);
+ if (current == prev_version_height) {
+ printf("Already compatible with %s\n", PREV_VERSION);
+ exit(0);
+ }
+ if (current >= num_migrations)
+ errx(ERROR_DBVERSION, "Unknown database version %zu: I only know up to %zu (%s)",
+ current, num_migrations, stringify(CLN_NEXT_VERSION));
+
+ /* current version is the last migration we did. */
+ while (current > prev_version_height) {
+ if (migrations[current].revertsql) {
+ stmt = db_prepare_v2(db, migrations[current].revertsql);
+ db_exec_prepared_v2(stmt);
+ tal_free(stmt);
+ }
+ if (migrations[current].revertfn) {
+ const char *error = migrations[current].revertfn(tmpctx, db);
+ if (error)
+ errx(ERROR_DBFAIL, "Downgrade failed: %s", error);
+ }
+ current--;
+ }
+
+ /* Finally update the version number in the version table */
+ stmt = db_prepare_v2(db, SQL("UPDATE version SET version=?;"));
+ db_bind_int(stmt, current);
+ db_exec_prepared_v2(stmt);
+ tal_free(stmt);
+
+ printf("Downgrade to %s succeeded. Committing.\n", PREV_VERSION);
+ db_commit_transaction(db);
+ tal_free(db);
+
+ if (!version_db(PREV_VERSION)->gossip_store_compatible) {
+ printf("Deleting incompatible gossip_store\n");
+ unlink(path_join(tmpctx, net_dir, "gossip_store"));
+ }
+}
+
+/*** We don't actually perform migrations, so these are stubs which abort. ***/
+/* Remake with `make update-mocks` or `make update-mocks/tools/lightning-downgrade.c` */
+
+/* AUTOGENERATED MOCKS START */
+/* Generated stub for fillin_missing_channel_blockheights */
+void fillin_missing_channel_blockheights(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "fillin_missing_channel_blockheights called!\n"); abort(); }
+/* Generated stub for fillin_missing_channel_id */
+void fillin_missing_channel_id(struct lightningd *ld UNNEEDED, struct db *db UNNEEDED)
+{ fprintf(stderr, "fillin_missing_channel_id called!\n"); abort(); }
+/* Generated stub for fillin_missing_lease_satoshi */
+void fillin_missing_lease_satoshi(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "fillin_missing_lease_satoshi called!\n"); abort(); }
+/* Generated stub for fillin_missing_local_basepoints */
+void fillin_missing_local_basepoints(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "fillin_missing_local_basepoints called!\n"); abort(); }
+/* Generated stub for fillin_missing_scriptpubkeys */
+void fillin_missing_scriptpubkeys(struct lightningd *ld UNNEEDED, struct db *db UNNEEDED)
+{ fprintf(stderr, "fillin_missing_scriptpubkeys called!\n"); abort(); }
+/* Generated stub for insert_addrtype_to_addresses */
+void insert_addrtype_to_addresses(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "insert_addrtype_to_addresses called!\n"); abort(); }
+/* Generated stub for migrate_channels_scids_as_integers */
+void migrate_channels_scids_as_integers(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_channels_scids_as_integers called!\n"); abort(); }
+/* Generated stub for migrate_convert_old_channel_keyidx */
+void migrate_convert_old_channel_keyidx(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_convert_old_channel_keyidx called!\n"); abort(); }
+/* Generated stub for migrate_datastore_commando_runes */
+void migrate_datastore_commando_runes(struct lightningd *ld UNNEEDED, struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_datastore_commando_runes called!\n"); abort(); }
+/* Generated stub for migrate_fail_pending_payments_without_htlcs */
+void migrate_fail_pending_payments_without_htlcs(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_fail_pending_payments_without_htlcs called!\n"); abort(); }
+/* Generated stub for migrate_fill_in_channel_type */
+void migrate_fill_in_channel_type(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_fill_in_channel_type called!\n"); abort(); }
+/* Generated stub for migrate_forwards_add_rowid */
+void migrate_forwards_add_rowid(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_forwards_add_rowid called!\n"); abort(); }
+/* Generated stub for migrate_from_account_db */
+void migrate_from_account_db(struct lightningd *ld UNNEEDED, struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_from_account_db called!\n"); abort(); }
+/* Generated stub for migrate_inflight_last_tx_to_psbt */
+void migrate_inflight_last_tx_to_psbt(struct lightningd *ld UNNEEDED, struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_inflight_last_tx_to_psbt called!\n"); abort(); }
+/* Generated stub for migrate_initialize_alias_local */
+void migrate_initialize_alias_local(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_initialize_alias_local called!\n"); abort(); }
+/* Generated stub for migrate_initialize_channel_htlcs_wait_indexes_and_fixup_forwards */
+void migrate_initialize_channel_htlcs_wait_indexes_and_fixup_forwards(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_initialize_channel_htlcs_wait_indexes_and_fixup_forwards called!\n"); abort(); }
+/* Generated stub for migrate_initialize_forwards_wait_indexes */
+void migrate_initialize_forwards_wait_indexes(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_initialize_forwards_wait_indexes called!\n"); abort(); }
+/* Generated stub for migrate_initialize_invoice_wait_indexes */
+void migrate_initialize_invoice_wait_indexes(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_initialize_invoice_wait_indexes called!\n"); abort(); }
+/* Generated stub for migrate_initialize_payment_wait_indexes */
+void migrate_initialize_payment_wait_indexes(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_initialize_payment_wait_indexes called!\n"); abort(); }
+/* Generated stub for migrate_invalid_last_tx_psbts */
+void migrate_invalid_last_tx_psbts(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_invalid_last_tx_psbts called!\n"); abort(); }
+/* Generated stub for migrate_invoice_created_index_var */
+void migrate_invoice_created_index_var(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_invoice_created_index_var called!\n"); abort(); }
+/* Generated stub for migrate_last_tx_to_psbt */
+void migrate_last_tx_to_psbt(struct lightningd *ld UNNEEDED, struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_last_tx_to_psbt called!\n"); abort(); }
+/* Generated stub for migrate_normalize_invstr */
+void migrate_normalize_invstr(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_normalize_invstr called!\n"); abort(); }
+/* Generated stub for migrate_our_funding */
+void migrate_our_funding(struct lightningd *ld UNNEEDED, struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_our_funding called!\n"); abort(); }
+/* Generated stub for migrate_payments_scids_as_integers */
+void migrate_payments_scids_as_integers(struct lightningd *ld UNNEEDED,
+ struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_payments_scids_as_integers called!\n"); abort(); }
+/* Generated stub for migrate_pr2342_feerate_per_channel */
+void migrate_pr2342_feerate_per_channel(struct lightningd *ld UNNEEDED, struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_pr2342_feerate_per_channel called!\n"); abort(); }
+/* Generated stub for migrate_remove_chain_moves_duplicates */
+void migrate_remove_chain_moves_duplicates(struct lightningd *ld UNNEEDED, struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_remove_chain_moves_duplicates called!\n"); abort(); }
+/* Generated stub for migrate_runes_idfix */
+void migrate_runes_idfix(struct lightningd *ld UNNEEDED, struct db *db UNNEEDED)
+{ fprintf(stderr, "migrate_runes_idfix called!\n"); abort(); }
+/* AUTOGENERATED MOCKS END */
diff --git a/wallet/Makefile b/wallet/Makefile
index 21f32d3..121a2e7 100644
--- a/wallet/Makefile
+++ b/wallet/Makefile
@@ -41,6 +41,8 @@ WALLET_SQL_FILES := \
wallet/test/run-wallet.c \
wallet/test/run-chain_moves_duplicate-detect.c \
wallet/test/run-migrate_remove_chain_moves_duplicates.c \
+ tools/lightning-downgrade.c \
+
wallet/statements_gettextgen.po: $(WALLET_SQL_FILES) $(FORCE)
@if $(call SHA256STAMP_CHANGED); then \
Why this scored 24/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.