bookkeeper: avoid wasteful refresh races.
What changed, and why it matters
This change is a hardening fix in Core Lightning's bookkeeping plugin. It prevents multiple simultaneous 'refresh' operations from racing each other and silently dropping duplicate results. Instead of ignoring cases where data arrives out of order, the code now asserts the expected order and queues follow-up refresh requests onto one shared in-flight operation. The visible risk is reduced reliability/correctness of accounting records rather than direct theft of funds.
Treat as a low-severity hardening patch. Review whether the new assertions can be triggered by any remaining race paths and ensure the destructor note in the comment is addressed if command lifetimes change. No urgent security response is indicated by the commit alone.
Security signals we found
Race condition in concurrent refresh handling
Silent dropping of out-of-order move events replaced by assertions
Shared mutable state (bkpr->rinfo) now synchronized via callback queueing
Potential accounting inconsistency if race had been hit before fix
Evidence from the diff
The patch refactors refresh_info in plugins/bkpr/bookkeeper.c to support multiple callbacks (struct refresh_cb) so concurrent refresh requests can piggy-back on a single outstanding listchainmoves/listchannelmoves RPC. It adds bkpr->rinfo to track the active refresh. It also replaces silent early returns in parse_and_log_chain_move and parse_and_log_channel_move with assertions that move database IDs are strictly increasing, turning a previously ignored race condition into a fatal invariant violation. The commit message frames this as a performance/correctness improvement (‘avoid wasteful refresh races’) and does not claim it fixes a security vulnerability.
Changed components
plugins/bkpr/bookkeeper.cplugins/bkpr/bookkeeper.hInspect captured patch +56 / −18
diff --git a/plugins/bkpr/bookkeeper.c b/plugins/bkpr/bookkeeper.c
index 10413756..0dda6ccb 100644
--- a/plugins/bkpr/bookkeeper.c
+++ b/plugins/bkpr/bookkeeper.c
@@ -53,12 +53,18 @@ void json_add_currencyrate(struct json_stream *result,
json_add_primitive_fmt(result, fieldname, "%f", *currencyrate);
}
-struct refresh_info {
- size_t calls_remaining;
+struct refresh_cb {
+ struct command *cmd;
struct command_result *(*cb)(struct command *, void *);
void *arg;
};
+/* If one is already running, simply append your own cb & arg */
+struct refresh_info {
+ size_t calls_remaining;
+ struct refresh_cb *callbacks;
+};
+
/* Rules: call use_rinfo when handing to a callback.
* Have the callback return rinfo_one_done(). */
static struct refresh_info *use_rinfo(struct refresh_info *rinfo)
@@ -75,10 +81,27 @@ static struct command_result *rinfo_one_done(struct command *cmd,
struct refresh_info *rinfo)
{
assert(rinfo->calls_remaining > 0);
- if (--rinfo->calls_remaining == 0)
- return rinfo->cb(cmd, rinfo->arg);
- else
- return command_still_pending(cmd);
+ if (--rinfo->calls_remaining == 0) {
+ struct command_result *first_ret = NULL;
+ struct bkpr *bkpr = bkpr_of(cmd->plugin);
+
+ assert(rinfo == bkpr->rinfo);
+ bkpr->rinfo = NULL;
+
+ /* We return first one (it's for this command), ignore the rest */
+ for (size_t i = 0; i < tal_count(rinfo->callbacks); i++) {
+ struct command_result *ret;
+ const struct refresh_cb *cb = &rinfo->callbacks[i];
+
+ ret = cb->cb(cb->cmd, cb->arg);
+ if (i == 0)
+ first_ret = ret;
+ }
+ tal_free(rinfo);
+ return first_ret;
+ }
+
+ return command_still_pending(cmd);
}
struct command_result *ignore_datastore_reply(struct command *cmd,
@@ -198,17 +221,31 @@ static struct command_result *refresh_moves_(struct command *cmd,
void *),
void *arg)
{
- struct refresh_info *rinfo = tal(cmd, struct refresh_info);
struct out_req *req;
struct bkpr *bkpr = bkpr_of(cmd->plugin);
+ struct refresh_cb refresh_cb;
+
+ refresh_cb.cmd = cmd;
+ refresh_cb.cb = cb;
+ refresh_cb.arg = arg;
+
+ /* If rinfo already running, just jump on that.
+ *
+ * NOTE: We assume cmd will not exit before refresh, which is
+ * currently true, otherwise we'd need a destructor to remove it. */
+ if (bkpr->rinfo) {
+ tal_arr_expand(&bkpr->rinfo->callbacks, refresh_cb);
+ return command_still_pending(cmd);
+ }
+
+ bkpr->rinfo = tal(bkpr, struct refresh_info);
+ bkpr->rinfo->calls_remaining = 0;
+ bkpr->rinfo->callbacks = tal_dup_arr(bkpr->rinfo, struct refresh_cb, &refresh_cb, 1, 0);
- rinfo->cb = cb;
- rinfo->arg = arg;
- rinfo->calls_remaining = 0;
req = jsonrpc_request_start(cmd, "listchainmoves",
listchainmoves_done,
plugin_broken_cb,
- use_rinfo(rinfo));
+ use_rinfo(bkpr->rinfo));
json_add_string(req->js, "index", "created");
json_add_u64(req->js, "start", bkpr->chainmoves_index + 1);
return send_outreq(req);
@@ -1314,10 +1351,8 @@ parse_and_log_chain_move(struct command *cmd,
if (e->origin_acct)
find_or_create_account(cmd, bkpr, e->origin_acct);
- /* Make this visible for queries (we expect increasing!). If we raced, this is not true. */
- if (e->db_id <= bkpr->chainmoves_index)
- return;
-
+ /* Make this visible for queries (we expect increasing!). */
+ assert(e->db_id > bkpr->chainmoves_index);
bkpr->chainmoves_index = e->db_id;
/* This event *might* have implications for account;
@@ -1433,9 +1468,8 @@ parse_and_log_channel_move(struct command *cmd,
" but no account exists %s",
acct_name);
- /* Make this visible for queries (we expect increasing!). If we raced, this is not true. */
- if (e->db_id <= bkpr->channelmoves_index)
- return;
+ /* Make this visible for queries (we expect increasing!). */
+ assert(e->db_id > bkpr->channelmoves_index);
bkpr->channelmoves_index = e->db_id;
/* Check for invoice desc data, necessary */
@@ -1702,6 +1736,7 @@ static const char *init(struct command *init_cmd, const char *b, const jsmntok_t
bkpr->descriptions = init_descriptions(bkpr, init_cmd);
bkpr->rebalances = init_rebalances(bkpr, init_cmd);
bkpr->blockheights = init_blockheights(bkpr, init_cmd);
+ bkpr->rinfo = NULL;
/* Callers always expect the wallet account to exist. */
find_or_create_account(init_cmd, bkpr, ACCOUNT_NAME_WALLET);
diff --git a/plugins/bkpr/bookkeeper.h b/plugins/bkpr/bookkeeper.h
index 1baf3096..a6c125c6 100644
--- a/plugins/bkpr/bookkeeper.h
+++ b/plugins/bkpr/bookkeeper.h
@@ -18,6 +18,9 @@ struct bkpr {
struct rebalances *rebalances;
struct blockheights *blockheights;
+ /* Any outstanding refresh. */
+ struct refresh_info *rinfo;
+
/* Where we're up to in listchainmoves, listchannelmoves */
u64 chainmoves_index, channelmoves_index;
Why this scored 21/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.