bookkeeper: make bookkeeper-currency dynamic.
What changed, and why it matters
This change lets users turn the bookkeeper's currency conversion feature on or off while the program is running, instead of only at startup. It also cleans up old exchange-rate data when the currency setting changes, and adds a safety check so a late answer from a previous currency request doesn't get stored. There is no obvious security bug here; it is a normal feature improvement.
No security action required. This is a feature enhancement. If reviewing, consider validating the currency string in the `option_currency` callback as noted by the FIXME, and ensure the dynamic teardown path is covered by fuzz or stress tests for rapid config changes.
Security signals we found
Race-condition mitigation: currency lookup result is discarded if the configured currency changed while the RPC was in flight.
Dynamic reconfiguration now tears down pending commands and clears cached rates, reducing stale-data risk.
No input validation on the currency string is performed (acknowledged with a FIXME).
No memory-safety defects are visible in the diff; allocations are paired with `tal_free`.
Evidence from the diff
The commit converts the bkpr-currency plugin option from a static startup option to a dynamic one via plugin_option_dynamic. It introduces an option_currency callback that, when the value changes, tears down in-flight currency RPC commands (currency_cmds), frees the existing currency_rates map, and resets state. It also snapshots the current currency into struct currency_time and verifies in currency_done that the returned rate still matches the active currency before storing it. The empty string is treated as ‘unset’.
Changed components
plugins/bkpr/bookkeeper.cplugins/bkpr/bookkeeper.hdoc/lightningd-config.5.mdtests/test_currencyrate.pyInspect captured patch +109 / −13
diff --git a/doc/lightningd-config.5.md b/doc/lightningd-config.5.md
index c64cbf67..25721771 100644
--- a/doc/lightningd-config.5.md
+++ b/doc/lightningd-config.5.md
@@ -573,7 +573,7 @@ command, so they invoices can also be paid onchain.
* **bkpr-currency**=*name* [plugin `bookkeeper`, *dynamic*]
- The *name* is an ISO-4217 name (e.g. USD), which will be passed to *currencyrate* to fetch the exchange rate for that currency on each bookkeeper event.
+ The *name* is an ISO-4217 name (e.g. USD), which will be passed to *currencyrate* to fetch the exchange rate for that currency on each bookkeeper event. Setting *name* to the empty string is equivalent not setting it.
### Networking options
diff --git a/plugins/bkpr/bookkeeper.c b/plugins/bkpr/bookkeeper.c
index 0dda6ccb..73283f0a 100644
--- a/plugins/bkpr/bookkeeper.c
+++ b/plugins/bkpr/bookkeeper.c
@@ -1131,6 +1131,7 @@ static struct command_result *lookup_invoice_desc(struct command *cmd,
struct currency_time {
struct refresh_info *rinfo;
+ const char *currency;
u64 timestamp;
};
@@ -1151,7 +1152,8 @@ static struct command_result *currency_done(struct command *cmd,
"Invalid currencyrate return '%.*s': %s",
json_tok_full_len(result),
json_tok_full(buf, result), err);
- } else {
+ /* 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,
@@ -1197,6 +1199,7 @@ static void lookup_currency(struct command *cmd,
ctime = tal(cmd, struct currency_time);
ctime->timestamp = timestamp;
+ ctime->currency = tal_strdup(ctime, bkpr->currency);
ctime->rinfo = use_rinfo(rinfo);
req = jsonrpc_request_start(cmd,
"currencyrate",
@@ -1753,14 +1756,46 @@ 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;
+}
+
+static char *option_currency(struct command *cmd,
+ const char *arg,
+ bool check_only,
+ char **p)
+{
+ struct bkpr *bkpr = bkpr_of(cmd->plugin);
+
+ assert(p == &bkpr->currency);
+
+ /* FIXME: Check for valid currency? */
+ if (check_only)
+ return NULL;
+
+ /* Changed? Clean up old one! */
+ if (bkpr->currency != NULL) {
+ /* Stop refreshes */
+ bkpr->currency_cmds = tal_free(bkpr->currency_cmds);
+ /* Clear existing values and free contents.*/
+ tal_free(bkpr->currency_rates);
+ bkpr->currency_rates = tal(bkpr, currencymap_t);
+ uintmap_init(bkpr->currency_rates);
+ memleak_add_helper(bkpr->currency_rates, memleak_scan_currencyrates);
+ bkpr->currency = tal_free(bkpr->currency);
}
+ /* Explicit empty string means unset. */
+ if (streq(arg, ""))
+ return NULL;
+
+ bkpr->currency = tal_strdup(bkpr, arg);
+ /* Reset this so we get a new message for new currency */
+ bkpr->warned_currency_fail = false;
+ /* If we're supposed to do currency conversions, we refresh
+ * all the time. */
+ bkpr->currency_cmds = aux_command(cmd);
+ currency_chainmoves_wait(bkpr->currency_cmds, NULL);
+ currency_channelmoves_wait(bkpr->currency_cmds, NULL);
return NULL;
}
@@ -1772,7 +1807,6 @@ int main(int argc, char *argv[])
/* No datadir is default */
bkpr = tal(NULL, struct bkpr);
bkpr->currency = NULL;
- bkpr->warned_currency_fail = false;
bkpr->currency_rates = tal(bkpr, currencymap_t);
uintmap_init(bkpr->currency_rates);
memleak_add_helper(bkpr->currency_rates, memleak_scan_currencyrates);
@@ -1781,10 +1815,10 @@ int main(int argc, char *argv[])
notifs, ARRAY_SIZE(notifs),
NULL, 0,
NULL, 0,
- plugin_option("bkpr-currency",
- "string",
- "Look up and record this currency on each event",
- charp_option, charp_jsonfmt, &bkpr->currency),
+ plugin_option_dynamic("bkpr-currency",
+ "string",
+ "Look up and record this currency on each event",
+ option_currency, charp_jsonfmt, &bkpr->currency),
NULL);
return 0;
diff --git a/plugins/bkpr/bookkeeper.h b/plugins/bkpr/bookkeeper.h
index a6c125c6..1e724ac7 100644
--- a/plugins/bkpr/bookkeeper.h
+++ b/plugins/bkpr/bookkeeper.h
@@ -30,6 +30,8 @@ struct bkpr {
currencymap_t *currency_rates;
/* True if we've warned about currency failures */
bool warned_currency_fail;
+ /* aux_command and parent of currency queries */
+ struct command *currency_cmds;
};
/* Add optional currencyrate for this timestamp */
diff --git a/tests/test_currencyrate.py b/tests/test_currencyrate.py
index 2181d4e5..a0064779 100644
--- a/tests/test_currencyrate.py
+++ b/tests/test_currencyrate.py
@@ -367,3 +367,63 @@ def test_bkpr_listaccountevents_realtime(node_factory, fake_rateserver):
assert events
for e in events:
assert e["currencyrate"] == old_median
+
+
+def test_bkpr_currency_dynamic(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",
+ ],
+ }
+ l1, l2 = node_factory.line_graph(2, opts=opts)
+
+ median_rate = (fake_rateserver["state"]["fast"] + fake_rateserver["state"]["slow"]) / 2
+
+ inv1 = l2.rpc.invoice(100000, "test_bkpr_currency_dynamic_1", "desc")
+ l1.rpc.pay(inv1["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'] == [])
+
+ # No bookkeeker-currency, no currencyrate
+ events = l1.rpc.bkpr_listaccountevents()["events"]
+ assert events
+ assert all("currencyrate" not in e for e in events)
+ num_events_1 = len(events)
+
+ l1.rpc.setconfig("bkpr-currency", "USD")
+
+ inv2 = l2.rpc.invoice(100000, "test_bkpr_currency_dynamic_2", "desc")
+ l1.rpc.pay(inv2["bolt11"])
+ wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['htlcs'] == [])
+
+ events = l1.rpc.bkpr_listaccountevents()["events"]
+ assert len(events) > num_events_1
+
+ old_events = events[:num_events_1]
+ new_events = events[num_events_1:]
+
+ assert all("currencyrate" not in e for e in old_events)
+ assert all(e["currencyrate"] == median_rate for e in new_events)
+
+ # Disables all currency conversions.
+ l1.rpc.setconfig("bkpr-currency", "")
+
+ inv3 = l2.rpc.invoice(100000, "test_bkpr_currency_dynamic_3", "desc")
+ l1.rpc.pay(inv3["bolt11"])
+ # If we don't wait here, we can get a spurious error from
+ # cln-currencyrate as fixture gets torn down!
+ wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['htlcs'] == [])
+
+ events = l1.rpc.bkpr_listaccountevents()["events"]
+ assert events
+ assert all("currencyrate" not in e for e in events)
Why this scored 17/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.