bkpr: warn if currency rate request is for old event.
What changed, and why it matters
This change adds a warning in the bookkeeper plugin when it tries to fetch a currency exchange rate for a payment event that happened more than 60 seconds ago. It is a defensive logging fix to avoid applying a current exchange rate to stale historical events. There is no vulnerability being exploited; it is a correctness and audit-quality improvement.
No security action required. Treat as a normal code-quality/logging improvement. Operators who see the new warning should understand that currency conversion was skipped for stale events and that historical bookkeeping may be incomplete for those entries.
Security signals we found
Defensive correctness check added to avoid misattributing current exchange rates to stale events
LOG_BROKEN warning introduced for stale currency-rate requests
Rate-limiting of warning to first occurrence to avoid log spam
Regression test added for stale-event handling
Evidence from the diff
The commit modifies plugins/bkpr/bookkeeper.c so that lookup_currency() checks whether the event timestamp is more than 60 seconds older than the current time. If so, it skips the currency-rate lookup and logs a LOG_BROKEN warning (once). An exception is made when no currency rates have ever been stored, to avoid transient warnings when currency conversion is first enabled. A regression test is added in tests/test_currencyrate.py covering both the first-enable case and the stale-event-after-restart case.
Changed components
plugins/bkpr/bookkeeper.ctests/test_currencyrate.pyInspect captured patch +82 / −0
diff --git a/plugins/bkpr/bookkeeper.c b/plugins/bkpr/bookkeeper.c
index ea0b3592..dc71c70f 100644
--- a/plugins/bkpr/bookkeeper.c
+++ b/plugins/bkpr/bookkeeper.c
@@ -1219,11 +1219,29 @@ static void lookup_currency(struct command *cmd,
{
struct out_req *req;
struct currency_time *ctime;
+ u64 now;
/* If we already have the timestamp, we're done */
if (uintmap_get(bkpr->currency_rates, 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 (!uintmap_empty(bkpr->currency_rates)
+ && !bkpr->warned_currency_fail) {
+ plugin_log(cmd->plugin, LOG_BROKEN,
+ "Event %s timestamp %"PRIu64" is %"PRIu64" seconds old: too old for current %s currencyrate (only logging first such event: there may be others)",
+ mvt_tag_str(primary_tag),
+ timestamp, now - timestamp,
+ bkpr->currency);
+ bkpr->warned_currency_fail = true;
+ }
+ return;
+ }
+
ctime = tal(cmd, struct currency_time);
ctime->timestamp = timestamp;
ctime->currency = tal_strdup(ctime, bkpr->currency);
diff --git a/tests/test_currencyrate.py b/tests/test_currencyrate.py
index e523e2e9..00b963d8 100644
--- a/tests/test_currencyrate.py
+++ b/tests/test_currencyrate.py
@@ -488,3 +488,67 @@ def test_bkpr_currencyrate_persisted(node_factory, fake_rateserver):
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}
+
+
+def test_bkpr_currencyrate_warns_for_old_events(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",
+ ],
+ 'may_reconnect': True,
+ 'broken_log': "too old for current USD currencyrate",
+ }
+ l1, l2 = node_factory.line_graph(2, opts=opts)
+
+ # 1. Create old events before bkpr-currency is enabled.
+ inv1 = l2.rpc.invoice(100000, "test_bkpr_currencyrate_warns_old_1", "desc")
+ l1.rpc.pay(inv1["bolt11"])
+ wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == [])
+ events = l1.rpc.bkpr_listaccountevents()["events"]
+ assert events
+ assert all("currencyrate" not in e for e in events)
+ time.sleep(61)
+
+ # 2. Enable bkpr-currency. This historical backfill case should not warn (transient)
+ l1.rpc.setconfig("bkpr-currency", "USD", True)
+
+ # New events.
+ inv2 = l2.rpc.invoice(100000, "test_bkpr_currencyrate_warns_old_2", "desc")
+ l1.rpc.pay(inv2["bolt11"])
+ wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == [])
+
+ # It does NOT complain about records before we set currency at all.
+ new_events = l1.rpc.bkpr_listaccountevents()["events"][len(events):]
+ assert new_events
+ assert all("currencyrate" in e for e in new_events)
+ assert not l1.daemon.is_in_log("too old for current USD currencyrate")
+
+ # 3. Stop bookkeeper so new events will not be processed yet (not a dynamic plugin!)
+ l1.daemon.opts['disable-plugin'] = "bookkeeper"
+ l1.restart()
+ l1.connect(l2)
+
+ # 4. Create new events while bookkeeper is stopped, then let them go stale.
+ inv3 = l2.rpc.invoice(100000, "test_bkpr_currencyrate_warns_old_3", "desc")
+ l1.rpc.pay(inv3["bolt11"])
+ wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == [])
+ time.sleep(61)
+
+ # 5. Restart with bookkeeper (with currency)
+ del l1.daemon.opts['disable-plugin']
+ l1.daemon.opts['bkpr-currency'] = "USD"
+ l1.restart()
+
+ # 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"))
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.