bookkeeper: persist currency conversions in the datastore.
What changed, and why it matters
This commit adds a feature to the bookkeeper plugin that saves fetched currency exchange rates to the node's internal datastore so they survive restarts. It is a functionality improvement, not a security fix. There is no indication in the commit or supplied references that this resolves a vulnerability or security issue.
No security action required. Treat as normal feature commit.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change persists currency conversion rates in the Core Lightning datastore. On currency option initialization, it loads previously stored rates via listdatastore under the key path [‘bookkeeper’,’currencyrate’,
Changed components
plugins/bkpr/bookkeeper.ctests/test_currencyrate.pyInspect captured patch +140 / −0
diff --git a/plugins/bkpr/bookkeeper.c b/plugins/bkpr/bookkeeper.c
index 06652c7d..ea0b3592 100644
--- a/plugins/bkpr/bookkeeper.c
+++ b/plugins/bkpr/bookkeeper.c
@@ -3,6 +3,7 @@
#include <ccan/array_size/array_size.h>
#include <ccan/cast/cast.h>
#include <ccan/json_escape/json_escape.h>
+#include <ccan/json_out/json_out.h>
#include <ccan/str/hex/hex.h>
#include <ccan/tal/str/str.h>
#include <ccan/tal/tal.h>
@@ -1138,6 +1139,15 @@ struct currency_time {
u64 timestamp;
};
+static struct command_result *currencyrate_ds_done(struct command *cmd,
+ const char *method,
+ const char *buf,
+ const jsmntok_t *result,
+ struct refresh_info *rinfo)
+{
+ return rinfo_one_done(cmd, rinfo);
+}
+
static struct command_result *currency_done(struct command *cmd,
const char *method,
const char *buf,
@@ -1163,6 +1173,20 @@ static struct command_result *currency_done(struct command *cmd,
ctime->timestamp,
p)) {
tal_free(p);
+ } else {
+ const char **key;
+ key = mkdatastorekey(tmpctx,
+ "bookkeeper",
+ "currencyrate",
+ ctime->currency,
+ take(tal_fmt(NULL, "%"PRIu64,
+ ctime->timestamp)));
+ jsonrpc_set_datastore_string(cmd, key,
+ tal_fmt(tmpctx, "%f", rate),
+ "must-create",
+ currencyrate_ds_done,
+ plugin_broken_cb,
+ use_rinfo(ctime->rinfo));
}
}
return rinfo_one_done(cmd, ctime->rinfo);
@@ -1776,6 +1800,58 @@ static const char *init(struct command *init_cmd, const char *b, const jsmntok_t
return NULL;
}
+static void load_currencyrates(struct command *cmd,
+ struct bkpr *bkpr)
+{
+ struct json_out *params;
+ const jsmntok_t *reply, *ds, *t;
+ const char *buf;
+ size_t i;
+
+ params = json_out_new(NULL);
+ json_out_start(params, NULL, '{');
+ json_out_start(params, "key", '[');
+ json_out_addstr(params, NULL, "bookkeeper");
+ json_out_addstr(params, NULL, "currencyrate");
+ json_out_addstr(params, NULL, bkpr->currency);
+ json_out_end(params, ']');
+ json_out_end(params, '}');
+ json_out_finished(params);
+
+ reply = jsonrpc_request_sync(tmpctx, cmd, "listdatastore",
+ take(params), &buf);
+ ds = json_get_member(buf, reply, "datastore");
+ json_for_each_arr(i, t, ds) {
+ const jsmntok_t *data, *keytok = json_get_member(buf, t, "key");
+ u64 ts;
+ double rate;
+
+ if (keytok->size != 4)
+ goto weird;
+
+ /* key = ["bookkeeper", "currencyrate", "USD", "timestamp"] */
+ if (!json_to_u64(buf, keytok + 4, &ts))
+ goto weird;
+
+ data = json_get_member(buf, t, "string");
+ if (!data)
+ goto weird;
+ if (!json_to_double(buf, data, &rate))
+ goto weird;
+
+ uintmap_add(bkpr->currency_rates,
+ ts,
+ tal_dup(bkpr->currency_rates, double, &rate));
+ continue;
+
+ weird:
+ plugin_log(cmd->plugin, LOG_BROKEN,
+ "Unexpected datastore rate entry '%.*s'",
+ json_tok_full_len(t),
+ json_tok_full(buf, t));
+ }
+}
+
static char *option_currency(struct command *cmd,
const char *arg,
bool check_only,
@@ -1809,6 +1885,9 @@ static char *option_currency(struct command *cmd,
/* Reset this so we get a new message for new currency */
bkpr->warned_currency_fail = false;
+ /* Start with saved currency rates */
+ load_currencyrates(cmd, bkpr);
+
/* Don't do this yet if we're before init! */
if (bkpr->accounts)
start_waiting_for_currency(bkpr, cmd);
diff --git a/tests/test_currencyrate.py b/tests/test_currencyrate.py
index a0064779..e523e2e9 100644
--- a/tests/test_currencyrate.py
+++ b/tests/test_currencyrate.py
@@ -2,6 +2,7 @@ import logging
import pytest
import threading
import time
+from utils import wait_for, only_one
from pyln.client import RpcError
from fixtures import * # noqa: F401,F403
from flask import Flask, jsonify
@@ -427,3 +428,63 @@ def test_bkpr_currency_dynamic(node_factory, fake_rateserver):
events = l1.rpc.bkpr_listaccountevents()["events"]
assert events
assert all("currencyrate" not in e for e in events)
+
+
+def test_bkpr_currencyrate_persisted(node_factory, fake_rateserver):
+ 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",
+ 'may_reconnect': True,
+ }
+ 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_currencyrate_persisted", "desc")
+ l1.rpc.pay(inv["bolt11"])
+ # Make sure it's fully resolved so we get all events now.
+ wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == [])
+
+ events = l1.rpc.bkpr_listaccountevents()["events"]
+ assert events
+ for e in events:
+ assert e["currencyrate"] == old_median
+
+ l1.restart()
+ l1.connect(l2)
+
+ fake_rateserver["state"]["fast"] = 200_000_000
+ fake_rateserver["state"]["slow"] = 150_000_000
+ new_median = (fake_rateserver["state"]["fast"] + fake_rateserver["state"]["slow"]) / 2
+
+ new_events = l1.rpc.bkpr_listaccountevents()["events"]
+ assert new_events == events
+
+ # And we can add more.
+ inv2 = l2.rpc.invoice(100000, "test_bkpr_currencyrate_persisted2", "desc")
+ l1.rpc.pay(inv2["bolt11"])
+ wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == [])
+
+ new_events = l1.rpc.bkpr_listaccountevents()["events"]
+ assert new_events[:len(events)] == events
+ assert new_events[len(events):]
+
+ for e in new_events[len(events):]:
+ assert e["currencyrate"] == new_median
+
+ # Underlying check: they should all be human readable timestamp->rate.
+ rates = l1.rpc.listdatastore(['bookkeeper', 'currencyrate', 'USD'])['datastore']
+ assert {r['key'][3] for r in rates} == {str(e['timestamp']) for e in new_events}
+ assert {float(r['string']) for r in rates} == {old_median, new_median}
Why this scored 18/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.