bookkeeper: save currencyrate ranges, not individual values.
What changed, and why it matters
This change is a performance and storage optimization for Core Lightning's bookkeeping plugin. Instead of saving every single currency exchange rate sample to the datastore, it now saves ranges of time where the rate stayed the same. This reduces disk writes and storage size but does not fix a security vulnerability. There is a small risk that the new lookup logic could return a slightly stale rate if a stored range is extended too far, but the code includes a 60-second tolerance and explicit checks to prevent hiding failed updates.
Treat as a routine optimization commit. Reviewers should verify that covering_currencyrate() cannot select a stale range across currency changes or plugin restarts, and that the 60-second tolerance is acceptable for the intended 10-minute poll interval. No security patch or incident response is indicated.
Security signals we found
Datastore schema change from per-timestamp double to timestamp-range integer encoding
New covering lookup could return a rate from an extended range rather than the exact timestamp
60-second tolerance explicitly limits range extension to avoid glossing over failed polls
No input validation weakening observed; malformed datastore entries still rejected in load_currencyrates
No memory safety primitives changed; allocation patterns remain tal-based
Evidence from the diff
The commit refactors currency-rate persistence in plugins/bkpr/bookkeeper.c. Previously each timestamp mapped to a double rate; now the uintmap stores struct currencyrate { u32 duration; u64 raw_rate; } keyed by the start timestamp, and the datastore value is formatted as raw_rate:duration. A covering_currencyrate() helper returns the rate for any timestamp that falls inside a stored range. On a new sample, the code tries to extend the previous range if the raw rate matches and the previous range ended within CURRENCYRATE_TOLERANCE_SECONDS (60 s). The test suite is updated to validate range coalescing, persistence, and reload. No cryptographic, network, or authorization changes are present.
Changed components
plugins/bkpr/bookkeeper.cplugins/bkpr/bookkeeper.htests/test_currencyrate.pybookkeeper currencyrate datastore formatInspect captured patch +166 / −36
diff --git a/plugins/bkpr/bookkeeper.c b/plugins/bkpr/bookkeeper.c
index dc71c70f..ee1f0b4b 100644
--- a/plugins/bkpr/bookkeeper.c
+++ b/plugins/bkpr/bookkeeper.c
@@ -39,20 +39,37 @@
#define CHAIN_MOVE "chain_mvt"
#define CHANNEL_MOVE "channel_mvt"
+/* We accept currencyrate from about 60 seconds ago */
+#define CURRENCYRATE_TOLERANCE_SECONDS 60
+
static struct bkpr *bkpr_of(struct plugin *plugin)
{
return plugin_get_data(plugin, struct bkpr);
}
+static const struct currencyrate *covering_currencyrate(const struct bkpr *bkpr,
+ u64 timestamp)
+{
+ /* We look for the previous entry, then check duration covers this. */
+ u64 ts = timestamp + 1;
+ const struct currencyrate *crate = uintmap_before(bkpr->currency_rates, &ts);
+
+ if (crate && ts + crate->duration > timestamp)
+ return crate;
+ return NULL;
+}
+
void json_add_currencyrate(struct json_stream *result,
const char *fieldname,
const struct bkpr *bkpr,
u64 timestamp)
{
- const double *currencyrate
- = uintmap_get(bkpr->currency_rates, timestamp);
- if (currencyrate)
- json_add_primitive_fmt(result, fieldname, "%f", *currencyrate);
+ const struct currencyrate *crate = covering_currencyrate(bkpr, timestamp);
+
+ if (crate)
+ json_add_primitive_fmt(result, fieldname, "%"PRIu64".%04"PRIu64,
+ crate->raw_rate / RATE_MUL_FACTOR,
+ crate->raw_rate % RATE_MUL_FACTOR);
}
struct refresh_cb {
@@ -1167,28 +1184,53 @@ static struct command_result *currency_done(struct command *cmd,
json_tok_full(buf, result), err);
/* Make sure they didn't change currency while we were out! */
} else if (bkpr->currency && streq(ctime->currency, bkpr->currency)) {
- double *p = tal_dup(bkpr->currency_rates, double, &rate);
- /* Can fail if we raced and asked twice */
- if (!uintmap_add(bkpr->currency_rates,
- 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));
+ char *val;
+ const char **key;
+ u64 raw_rate = (u64)(rate * RATE_MUL_FACTOR);
+ /* Can we extend previous entry */
+ u64 ts = ctime->timestamp + 1;
+ struct currencyrate *crate
+ = uintmap_before(bkpr->currency_rates, &ts);
+
+ if (crate) {
+ u64 crate_end = ts + crate->duration;
+ /* If we raced, it might already be there! */
+ if (crate_end > ctime->timestamp)
+ goto out;
+
+ /* Reuse if it's recent, and the same rate.
+ * (Recent check avoid glossing over failures, if we
+ * couldn't get reliable data). */
+ if (raw_rate == crate->raw_rate
+ && crate_end + CURRENCYRATE_TOLERANCE_SECONDS > ctime->timestamp) {
+ uintmap_del(bkpr->currency_rates, ts);
+ crate->duration = ctime->timestamp - ts + 1;
+ } else {
+ crate = NULL;
+ }
+ }
+
+ if (!crate) {
+ ts = ctime->timestamp;
+ crate = tal(bkpr->currency_rates, struct currencyrate);
+ crate->raw_rate = raw_rate;
+ crate->duration = 1;
}
+ uintmap_add(bkpr->currency_rates, ts, crate);
+ val = tal_fmt(tmpctx, "%"PRIu64":%u",
+ crate->raw_rate, crate->duration);
+ key = mkdatastorekey(tmpctx,
+ "bookkeeper",
+ "currencyrate",
+ ctime->currency,
+ take(tal_fmt(NULL, "%"PRIu64, ts)));
+ jsonrpc_set_datastore_string(cmd, key, val,
+ "create-or-replace",
+ currencyrate_ds_done,
+ plugin_broken_cb,
+ use_rinfo(ctime->rinfo));
}
+out:
return rinfo_one_done(cmd, ctime->rinfo);
}
@@ -1222,14 +1264,14 @@ static void lookup_currency(struct command *cmd,
u64 now;
/* If we already have the timestamp, we're done */
- if (uintmap_get(bkpr->currency_rates, timestamp) != NULL)
+ if (covering_currencyrate(bkpr, timestamp) != NULL)
return;
/* If it's more than 60 seconds old, we should not apply the current
* conversion. This can definitely happen when we first turn on
* currency conversion though, so don't print in that case. */
now = clock_time().ts.tv_sec;
- if (now > timestamp + 60) {
+ if (now > timestamp + CURRENCYRATE_TOLERANCE_SECONDS) {
if (!uintmap_empty(bkpr->currency_rates)
&& !bkpr->warned_currency_fail) {
plugin_log(cmd->plugin, LOG_BROKEN,
@@ -1841,8 +1883,9 @@ static void load_currencyrates(struct command *cmd,
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");
+ jsmntok_t ratetok, durationtok;
u64 ts;
- double rate;
+ struct currencyrate *crate;
if (keytok->size != 4)
goto weird;
@@ -1854,12 +1897,17 @@ static void load_currencyrates(struct command *cmd,
data = json_get_member(buf, t, "string");
if (!data)
goto weird;
- if (!json_to_double(buf, data, &rate))
+
+ if (!split_tok(buf, data, ':', &ratetok, &durationtok))
+ goto weird;
+ crate = tal(bkpr->currency_rates, struct currencyrate);
+ if (!json_to_u64(buf, &ratetok, &crate->raw_rate)
+ || !json_to_u32(buf, &durationtok, &crate->duration)) {
+ tal_free(crate);
goto weird;
+ }
- uintmap_add(bkpr->currency_rates,
- ts,
- tal_dup(bkpr->currency_rates, double, &rate));
+ uintmap_add(bkpr->currency_rates, ts, crate);
continue;
weird:
diff --git a/plugins/bkpr/bookkeeper.h b/plugins/bkpr/bookkeeper.h
index 1e724ac7..76234fea 100644
--- a/plugins/bkpr/bookkeeper.h
+++ b/plugins/bkpr/bookkeeper.h
@@ -7,8 +7,16 @@
struct command;
+/* Most currencies have 2 decimal places, but 4 is the current maximum. */
+#define RATE_MUL_FACTOR 10000
+
+struct currencyrate {
+ u32 duration;
+ u64 raw_rate;
+};
+
/* For allocation convenience. */
-typedef UINTMAP(double *) currencymap_t;
+typedef UINTMAP(struct currencyrate *) currencymap_t;
struct bkpr {
/* The datastore-backed lookup tables for our annotations */
diff --git a/tests/test_currencyrate.py b/tests/test_currencyrate.py
index 00b963d8..a25efbd4 100644
--- a/tests/test_currencyrate.py
+++ b/tests/test_currencyrate.py
@@ -484,10 +484,20 @@ def test_bkpr_currencyrate_persisted(node_factory, fake_rateserver):
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}
+ # Underlying datastore check: they should all be human readable timestamp->rate.
+ stored_rates = {}
+ for r in l1.rpc.listdatastore(['bookkeeper', 'currencyrate', 'USD'])['datastore']:
+ start = int(r['key'][3])
+ raw_rate, duration = r['string'].split(':')
+ raw_rate = int(raw_rate)
+ duration = int(duration)
+
+ for t in range(start, start + duration):
+ assert t not in stored_rates
+ stored_rates[t] = raw_rate
+
+ for e in new_events:
+ assert e["currencyrate"] == stored_rates[e["timestamp"]] / 100
def test_bkpr_currencyrate_warns_for_old_events(node_factory, fake_rateserver):
@@ -552,3 +562,67 @@ def test_bkpr_currencyrate_warns_for_old_events(node_factory, fake_rateserver):
# 6. It should now complain about processing stale events with conversion enabled.
# (Could be early in startup!)
wait_for(lambda: l1.daemon.is_in_log("too old for current USD currencyrate"))
+
+
+def test_bkpr_currencyrate_ranges(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,
+ }
+ # This generates onchain events.
+ l1, l2 = node_factory.line_graph(2, opts=opts)
+ old_median = (fake_rateserver["state"]["fast"] + fake_rateserver["state"]["slow"]) / 2
+
+ # This generates a channel event: be sure timestamp is different.
+ time.sleep(1)
+
+ inv1 = l2.rpc.invoice(100000, "test_bkpr_currencyrate_ranges_1", "desc")
+ l1.rpc.pay(inv1["bolt11"])
+ wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == [])
+
+ # Now we change the rate (and make sure time goes forward so it re-checks!)
+ time.sleep(1)
+
+ 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
+
+ l1.restart()
+ l1.connect(l2)
+
+ inv2 = l2.rpc.invoice(100000, "test_bkpr_currencyrate_ranges_2", "desc")
+ l1.rpc.pay(inv2["bolt11"])
+ wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == [])
+
+ # Calling this here makes sure it's finished processing currencyrates
+ events = l1.rpc.bkpr_listaccountevents()
+ rates = l1.rpc.listdatastore(['bookkeeper', 'currencyrate', 'USD'])['datastore']
+
+ # Same-rate timestamps should be coalesced into one stored range.
+ assert len(rates) == 2
+
+ assert int(rates[0]['key'][3]) < int(rates[1]['key'][3])
+ raw_rate, duration = rates[0]['string'].split(':')
+ assert int(raw_rate) == int(old_median * 10000)
+ assert int(duration) >= 2
+
+ raw_rate, duration = rates[1]['string'].split(':')
+ assert int(raw_rate) == int(new_median * 10000)
+ assert int(duration) >= 1
+
+ # We will load them fine on restart, too.
+ l1.restart()
+ assert l1.rpc.bkpr_listaccountevents() == events
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.