currencyrate: change `currencyrates` to `listcurrencyrates` which is more CLN-ish.
What changed, and why it matters
This commit renames a read-only exchange-rate API from 'currencyrates' to 'listcurrencyrates' and changes the response format from a flat map of millisatoshi-per-currency-unit values to a structured list of source names and BTC prices. It also adds the missing JSON schema and documentation. There is no security fix or vulnerability here; it is a normal API cleanup and documentation improvement.
No security action required. Reviewers may want to confirm that downstream clients relying on the old 'currencyrates' command and flat-map response are updated, since this is a breaking API change.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch is purely a naming and formatting refactor of the currencyrate plugin’s RPC. The old ‘currencyrates’ command is replaced by ‘listcurrencyrates’. The internal function is renamed, the response shape changes from {source: msat} to {currencyrates: [{source, amount}]}, and the amount field now reports the raw BTC price rather than the derived millisatoshi-per-unit. Tests are updated to match the new response shape and values. A JSON schema and generated man page are added. No input validation, authentication, network, or cryptographic logic is modified.
Changed components
plugins/currencyrate-plugin/src/main.rsdoc/schemas/listcurrencyrates.jsoncontrib/msggen/msggen/schema.jsontests/test_currencyrate.pyInspect captured patch +195 / −37
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index 70c47f45..fced75f1 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -20750,6 +20750,73 @@
}
]
},
+ "listcurrencyrates.json": {
+ "$schema": "../rpc-schema-draft.json",
+ "type": "object",
+ "rpc": "currencyrates",
+ "added": "v26.04",
+ "title": "Command to list millisatoshis-per-unit from each configured source",
+ "description": [
+ "The **listcurrencyrates** RPC command returns the number of units of a given currency for one BTC from each configured exchange-rate source."
+ ],
+ "request": {
+ "required": [
+ "currency"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "currency": {
+ "type": "string",
+ "description": [
+ "The ISO4217 currency code to query, eg USD.",
+ "The plugin normalizes this value to uppercase before querying sources."
+ ]
+ }
+ }
+ },
+ "response": {
+ "required": [
+ "currencyrates"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "currencyrates": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "source",
+ "amount"
+ ],
+ "properties": {
+ "source": {
+ "type": "string",
+ "description": [
+ "The name of the source of this rate (see `currencyrate-add-source` in lightningd-config)"
+ ]
+ },
+ "amount": {
+ "type": "number",
+ "description": [
+ "The amount of currency for 1 BTC."
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "author": [
+ "daywalker90 is mainly responsible."
+ ],
+ "see_also": [
+ "lightning-currencyconvert(7)"
+ ],
+ "resources": [
+ "Main web site: [https://github.com/ElementsProject/lightning](https://github.com/ElementsProject/lightning)"
+ ]
+ },
"listdatastore.json": {
"$schema": "../rpc-schema-draft.json",
"type": "object",
diff --git a/doc/Makefile b/doc/Makefile
index 4898e6e5..24aa4f42 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -85,6 +85,7 @@ MARKDOWNPAGES := doc/addgossip.7 \
doc/listchannels.7 \
doc/listclosedchannels.7 \
doc/listconfigs.7 \
+ doc/listcurrencyrates.7 \
doc/listdatastore.7 \
doc/listforwards.7 \
doc/listfunds.7 \
diff --git a/doc/index.rst b/doc/index.rst
index edf186b9..6540ea2b 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -100,6 +100,7 @@ Core Lightning Documentation
listchannels <listchannels.7.md>
listclosedchannels <listclosedchannels.7.md>
listconfigs <listconfigs.7.md>
+ listcurrencyrates <listcurrencyrates.7.md>
listdatastore <listdatastore.7.md>
listforwards <listforwards.7.md>
listfunds <listfunds.7.md>
diff --git a/doc/schemas/listcurrencyrates.json b/doc/schemas/listcurrencyrates.json
new file mode 100644
index 00000000..9b37a792
--- /dev/null
+++ b/doc/schemas/listcurrencyrates.json
@@ -0,0 +1,67 @@
+{
+ "$schema": "../rpc-schema-draft.json",
+ "type": "object",
+ "rpc": "currencyrates",
+ "added": "v26.04",
+ "title": "Command to list millisatoshis-per-unit from each configured source",
+ "description": [
+ "The **listcurrencyrates** RPC command returns the number of units of a given currency for one BTC from each configured exchange-rate source."
+ ],
+ "request": {
+ "required": [
+ "currency"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "currency": {
+ "type": "string",
+ "description": [
+ "The ISO4217 currency code to query, eg USD.",
+ "The plugin normalizes this value to uppercase before querying sources."
+ ]
+ }
+ }
+ },
+ "response": {
+ "required": [
+ "currencyrates"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "currencyrates": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "source",
+ "amount"
+ ],
+ "properties": {
+ "source": {
+ "type": "string",
+ "description": [
+ "The name of the source of this rate (see `currencyrate-add-source` in lightningd-config)"
+ ]
+ },
+ "amount": {
+ "type": "number",
+ "description": [
+ "The amount of currency for 1 BTC."
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "author": [
+ "daywalker90 is mainly responsible."
+ ],
+ "see_also": [
+ "lightning-currencyconvert(7)"
+ ],
+ "resources": [
+ "Main web site: [https://github.com/ElementsProject/lightning](https://github.com/ElementsProject/lightning)"
+ ]
+}
diff --git a/plugins/currencyrate-plugin/src/main.rs b/plugins/currencyrate-plugin/src/main.rs
index d0d3f475..d642a368 100644
--- a/plugins/currencyrate-plugin/src/main.rs
+++ b/plugins/currencyrate-plugin/src/main.rs
@@ -59,8 +59,8 @@ median from currencyrates results",
.usage("amount currency"),
)
.rpcmethod_from_builder(
- RpcMethodBuilder::new("currencyrates", currencyrates)
- .description("Returns the number of msats per unit from every source")
+ RpcMethodBuilder::new("listcurrencyrates", listcurrencyrates)
+ .description("Returns the BTC price for the currency from every source")
.usage("currency"),
)
.dynamic()
@@ -138,7 +138,7 @@ async fn currencyconvert(plugin: Plugin<PluginState>, args: Value) -> Result<Val
}
}
-async fn currencyrates(plugin: Plugin<PluginState>, args: Value) -> Result<Value, anyhow::Error> {
+async fn listcurrencyrates(plugin: Plugin<PluginState>, args: Value) -> Result<Value, anyhow::Error> {
let currency = match args {
Value::Array(values) => {
let currency = values
@@ -166,14 +166,21 @@ async fn currencyrates(plugin: Plugin<PluginState>, args: Value) -> Result<Value
match oracle.get_all_rates(¤cy).await {
Ok(result) => {
- let mut map = serde_json::Map::new();
- for source_result in result {
- let msat = (MSAT_PER_BTC / source_result.price).round() as u64;
- map.insert(source_result.name.clone(), json!(msat));
- }
- Ok(json!(map))
+ let currencyrates = result
+ .into_iter()
+ .map(|source_result| {
+ json!({
+ "source": source_result.name,
+ "amount": source_result.price,
+ })
+ })
+ .collect::<Vec<_>>();
+
+ Ok(json!({
+ "currencyrates": currencyrates,
+ }))
}
- Err(e) => Err(anyhow!("Error converting currency: {e}")),
+ Err(e) => Err(anyhow!("Error listing currency rates: {e}")),
}
}
diff --git a/tests/test_currencyrate.py b/tests/test_currencyrate.py
index bc025a49..68866735 100644
--- a/tests/test_currencyrate.py
+++ b/tests/test_currencyrate.py
@@ -11,14 +11,31 @@ from werkzeug.serving import make_server
LOGGER = logging.getLogger(__name__)
+def median_range(amount, rateslist):
+ """Return the reasonable median of these rates (similar to what currencyrate does)"""
+ rates = [entry["amount"] for entry in rateslist]
+ rates.sort()
+
+ if len(rates) % 2 == 1:
+ btc = rates[len(rates) // 2]
+ else:
+ btc = (rates[len(rates) - 1] + rates[len(rates) - 1]) / 2
+
+ msats = amount * 100_000_000_000 / btc
+
+ # Give it +/- 1%
+ return range(int(msats * 0.99), int(msats * 1.01))
+
+
def test_apis_batch1(node_factory):
opts = {
"currencyrate-disable-source": ["bitstamp", "coinbase"],
}
l1 = node_factory.get_node(options=opts)
- rates = l1.rpc.call("currencyrates", ["USD"])
- LOGGER.info(rates)
+ rateslist = l1.rpc.call("listcurrencyrates", ["USD"])['currencyrates']
+ LOGGER.info(rateslist)
+ rates = {entry["source"]: entry["amount"] for entry in rateslist}
assert "bitstamp" not in rates
assert "coinbase" not in rates
@@ -29,11 +46,12 @@ def test_apis_batch1(node_factory):
assert "coindesk" in rates
assert "binance" in rates
- assert rates["coingecko"] > 0
- assert rates["kraken"] > 0
- assert rates["blockchain.info"] > 0
- assert rates["coindesk"] > 0
- assert rates["binance"] > 0
+ # Death to the 58k gang!
+ assert rates["coingecko"] > 58000
+ assert rates["kraken"] > 58000
+ assert rates["blockchain.info"] > 58000
+ assert rates["coindesk"] > 58000
+ assert rates["binance"] > 58000
rates = [
rates["coingecko"],
@@ -50,9 +68,7 @@ def test_apis_batch1(node_factory):
assert "msat" in convert
assert convert["msat"] > 0
-
- assert convert["msat"] >= (rates[0] - 1) * 100
- assert convert["msat"] <= (rates[len(rates) - 1] + 1) * 100
+ assert convert["msat"] in median_range(100, rateslist)
def test_apis_batch2(node_factory):
@@ -67,8 +83,9 @@ def test_apis_batch2(node_factory):
}
l1 = node_factory.get_node(options=opts)
- rates = l1.rpc.call("currencyrates", ["USD"])
- LOGGER.info(rates)
+ rateslist = l1.rpc.call("listcurrencyrates", ["USD"])['currencyrates']
+ LOGGER.info(rateslist)
+ rates = {entry["source"]: entry["amount"] for entry in rateslist}
assert "bitstamp" in rates
assert "coinbase" in rates
@@ -93,9 +110,7 @@ def test_apis_batch2(node_factory):
assert "msat" in convert
assert convert["msat"] > 0
-
- assert convert["msat"] >= (rates[0] - 1) * 100
- assert convert["msat"] <= (rates[len(rates) - 1] + 1) * 100
+ assert convert["msat"] in median_range(100, rateslist)
def test_custom_source(node_factory):
@@ -116,8 +131,9 @@ def test_custom_source(node_factory):
}
l1 = node_factory.get_node(options=opts)
- rates = l1.rpc.call("currencyrates", ["USD"])
- LOGGER.info(rates)
+ rateslist = l1.rpc.call("listcurrencyrates", ["USD"])['currencyrates']
+ LOGGER.info(rateslist)
+ rates = {entry["source"]: entry["amount"] for entry in rateslist}
assert "bitstamp" not in rates
assert "coinbase" not in rates
@@ -144,9 +160,7 @@ def test_custom_source(node_factory):
assert "msat" in convert
assert convert["msat"] > 0
-
- assert convert["msat"] >= (rates[0] - 1) * 100
- assert convert["msat"] <= (rates[len(rates) - 1] + 1) * 100
+ assert convert["msat"] in median_range(100, rateslist)
def test_no_sources(node_factory):
@@ -165,9 +179,9 @@ def test_no_sources(node_factory):
with pytest.raises(
RpcError,
- match="Unknown command 'currencyrates'",
+ match="Unknown command 'listcurrencyrates'",
):
- rates = l1.rpc.call("currencyrates", ["USD"])
+ rates = l1.rpc.call("listcurrencyrates", ["USD"])
LOGGER.info(rates)
@@ -181,7 +195,7 @@ def test_invalid_currency(node_factory):
RpcError,
match=r"no results for `XXX`, is the currency supported\? Check the logs!",
):
- rates = l1.rpc.call("currencyrates", ["XXX"])
+ rates = l1.rpc.call("listcurrencyrates", ["XXX"])
LOGGER.info(rates)
l1.daemon.logsearch_start = needle
@@ -257,14 +271,15 @@ def test_cached_median(node_factory, fake_rateserver):
}
l1 = node_factory.get_node(options=opts)
- rates = l1.rpc.call("currencyrates", ["USD"])
- LOGGER.info(rates)
+ rateslist = l1.rpc.call("listcurrencyrates", ["USD"])['currencyrates']
+ LOGGER.info(rateslist)
+ rates = {entry["source"]: entry["amount"] for entry in rateslist}
assert "fast" in rates
assert "slow" in rates
- assert rates["fast"] == 1000
- assert rates["slow"] == 2000
+ assert rates["fast"] == 100_000_000
+ assert rates["slow"] == 50_000_000
# Cached result should be median of two rates.
median_rate = (100_000_000 + 50_000_000) / 2
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.