bookkeeper: keep up with new entries if we're doing currency conversion.
What changed, and why it matters
This commit fixes a bookkeeping timing issue in Core Lightning. When the bookkeeper plugin is configured to show amounts in a fiat currency, it previously only refreshed currency conversion rates when a user ran a command. Now it actively listens for new on-chain and channel events and refreshes rates immediately, so reports stay up to date without waiting for a manual command. The change is a correctness/quality improvement, not a security vulnerability fix.
No security action required. Treat as normal code-quality / feature update during regular maintenance.
Security signals we found
No security-relevant signals in commit message or diff
Change is functional correctness / data freshness improvement
No input validation, memory safety, authentication, or authorization changes
No CVE, advisory, or security attribution present in supplied materials
Evidence from the diff
The patch adds two long-polling wait loops in plugins/bkpr/bookkeeper.c that subscribe to the ‘chainmoves’ and ‘channelmoves’ subsystems via the ‘wait’ JSON-RPC method. When either subsystem reports a new event, the bookkeeper calls refresh_moves() to update currency conversion rates. Previously, refresh only happened lazily on RPC commands. The test changes add a realtime test verifying that rates are refreshed promptly after a payment event, and that old cached rates are used for already-recorded events even after the external rate source changes.
Changed components
plugins/bkpr/bookkeeper.ctests/test_currencyrate.pyInspect captured patch +125 / −14
diff --git a/plugins/bkpr/bookkeeper.c b/plugins/bkpr/bookkeeper.c
index fcab7a0a..10413756 100644
--- a/plugins/bkpr/bookkeeper.c
+++ b/plugins/bkpr/bookkeeper.c
@@ -1633,6 +1633,64 @@ static void memleak_scan_currencyrates(struct htable *memtable,
memleak_scan_uintmap(memtable, currency_rates);
}
+/* Whenever wait says the chainmoves or channelmoves fires, we refresh */
+
+/* Mutual recursion */
+static struct command_result *
+currency_chainmoves_wait(struct command *auxcmd, void *unused);
+static struct command_result *
+currency_channelmoves_wait(struct command *auxcmd, void *unused);
+
+static struct command_result *
+currency_chainmoves_wait_done(struct command *auxcmd,
+ const char *methodname,
+ const char *buf,
+ const jsmntok_t *result,
+ void *unused)
+{
+ return refresh_moves(auxcmd, currency_chainmoves_wait, NULL);
+}
+
+static struct command_result *
+currency_channelmoves_wait_done(struct command *auxcmd,
+ const char *methodname,
+ const char *buf,
+ const jsmntok_t *result,
+ void *unused)
+{
+ return refresh_moves(auxcmd, currency_channelmoves_wait, NULL);
+}
+
+static struct command_result *
+currency_chainmoves_wait(struct command *auxcmd, void *unused)
+{
+ struct bkpr *bkpr = bkpr_of(auxcmd->plugin);
+ struct out_req *req;
+ req = jsonrpc_request_start(auxcmd, "wait",
+ currency_chainmoves_wait_done,
+ plugin_broken_cb,
+ NULL);
+ json_add_string(req->js, "subsystem", "chainmoves");
+ json_add_string(req->js, "indexname", "created");
+ json_add_u64(req->js, "nextvalue", bkpr->chainmoves_index+1);
+ return send_outreq(req);
+}
+
+static struct command_result *
+currency_channelmoves_wait(struct command *auxcmd, void *unused)
+{
+ struct bkpr *bkpr = bkpr_of(auxcmd->plugin);
+ struct out_req *req;
+ req = jsonrpc_request_start(auxcmd, "wait",
+ currency_channelmoves_wait_done,
+ plugin_broken_cb,
+ NULL);
+ json_add_string(req->js, "subsystem", "channelmoves");
+ json_add_string(req->js, "indexname", "created");
+ json_add_u64(req->js, "nextvalue", bkpr->channelmoves_index+1);
+ return send_outreq(req);
+}
+
static const char *init(struct command *init_cmd, const char *b, const jsmntok_t *t)
{
struct plugin *p = init_cmd->plugin;
@@ -1660,6 +1718,14 @@ static const char *init(struct command *init_cmd, const char *b, const jsmntok_t
} else
bkpr->chainmoves_index = 0;
+ /* If we're supposed to do currency conversions, we refresh
+ * all the time. */
+ if (bkpr->currency) {
+ struct command *auxcmd = aux_command(init_cmd);
+ currency_chainmoves_wait(auxcmd, NULL);
+ currency_channelmoves_wait(auxcmd, NULL);
+ }
+
return NULL;
}
diff --git a/tests/test_currencyrate.py b/tests/test_currencyrate.py
index f97664ca..2181d4e5 100644
--- a/tests/test_currencyrate.py
+++ b/tests/test_currencyrate.py
@@ -234,23 +234,28 @@ class _ServerThread(threading.Thread):
@pytest.fixture
def fake_rateserver():
app = Flask(__name__)
+ state = {
+ "fast": 100_000_000,
+ "slow": 50_000_000,
+ "slow_delay": 1,
+ }
@app.get("/fast")
def fast():
- # 1e11 / 100_000_000 = 1000 msat per USD
- return jsonify({"price": 100_000_000})
+ return jsonify({"price": state["fast"]})
@app.get("/slow")
def slow():
- # Make this complete later, so it becomes latest_fresh_price().
- time.sleep(1)
- # 1e11 / 50_000_000 = 2000 msat per USD
- return jsonify({"price": 50_000_000})
+ time.sleep(state["slow_delay"])
+ return jsonify({"price": state["slow"]})
srv = _ServerThread(app)
srv.start()
try:
- yield f"http://127.0.0.1:{srv.port}"
+ yield {
+ "url": f"http://127.0.0.1:{srv.port}",
+ "state": state,
+ }
finally:
srv.shutdown()
srv.join()
@@ -269,8 +274,8 @@ def test_cached_median(node_factory, fake_rateserver):
"binance",
],
"currencyrate-add-source": [
- f"fast,{fake_rateserver}/fast,price",
- f"slow,{fake_rateserver}/slow,price",
+ f"fast,{fake_rateserver['url']}/fast,price",
+ f"slow,{fake_rateserver['url']}/slow,price",
],
}
l1 = node_factory.get_node(options=opts)
@@ -282,11 +287,11 @@ def test_cached_median(node_factory, fake_rateserver):
assert "fast" in rates
assert "slow" in rates
- assert rates["fast"] == 100_000_000
- assert rates["slow"] == 50_000_000
+ assert rates["fast"] == fake_rateserver["state"]["fast"]
+ assert rates["slow"] == fake_rateserver["state"]["slow"]
# Cached result should be median of two rates.
- median_rate = (100_000_000 + 50_000_000) / 2
+ median_rate = (fake_rateserver["state"]["fast"] + fake_rateserver["state"]["slow"]) / 2
convert = l1.rpc.call("currencyconvert", [100, "USD"])
LOGGER.info(convert)
@@ -306,8 +311,8 @@ def test_bkpr_listaccountevents_currencyrate(node_factory, fake_rateserver):
"binance",
],
"currencyrate-add-source": [
- f"fast,{fake_rateserver}/fast,price",
- f"slow,{fake_rateserver}/slow,price",
+ f"fast,{fake_rateserver['url']}/fast,price",
+ f"slow,{fake_rateserver['url']}/slow,price",
],
"bkpr-currency": "USD",
}
@@ -322,3 +327,43 @@ def test_bkpr_listaccountevents_currencyrate(node_factory, fake_rateserver):
median_rate = (100_000_000 + 50_000_000) / 2
for e in events:
assert e["currencyrate"] == median_rate
+
+
+def test_bkpr_listaccountevents_realtime(node_factory, fake_rateserver):
+ """Make sure we don't wait for bkpr command to look up rates!"""
+ opts = {
+ "currencyrate-disable-source": [
+ "bitstamp",
+ "coinbase",
+ "coingecko",
+ "kraken",
+ "blockchain.info",
+ "coindesk",
+ "binance",
+ ],
+ "currencyrate-add-source": [
+ f"fast,{fake_rateserver['url']}/fast,price",
+ f"slow,{fake_rateserver['url']}/slow,price",
+ ],
+ "bkpr-currency": "USD",
+ }
+ l1, l2 = node_factory.line_graph(2, opts=opts)
+
+ old_median = (fake_rateserver["state"]["fast"] + fake_rateserver["state"]["slow"]) / 2
+
+ inv = l2.rpc.invoice(100000, "test_bkpr_listaccountevents_realtime", "desc")
+ l1.rpc.pay(inv["bolt11"])
+ # We want this event in the list, so wait until it's totally closed.
+ wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['htlcs'] == [])
+
+ # We could put a log msg inside bookkeeper, but that's spammy.
+ time.sleep(10)
+
+ # Change rates. But old ones should be used!
+ fake_rateserver["state"]["fast"] = 200_000_000
+ fake_rateserver["state"]["slow"] = 150_000_000
+
+ events = l1.rpc.bkpr_listaccountevents()["events"]
+ assert events
+ for e in events:
+ assert e["currencyrate"] == old_median
Why this scored 16/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.