Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .msggen.json
Original file line number Diff line number Diff line change
Expand Up @@ -1425,7 +1425,8 @@
"CurrencyConvert.msat": 1
},
"CurrencyrateRequest": {
"CurrencyRate.currency": 1
"CurrencyRate.currency": 1,
"CurrencyRate.source": 2
},
"CurrencyrateResponse": {
"CurrencyRate.rate": 1
Expand Down Expand Up @@ -6339,6 +6340,10 @@
"added": "v26.04",
"deprecated": null
},
"CurrencyRate.source": {
"added": "v26.06",
"deprecated": null
},
"Datastore": {
"added": "pre-v0.10.1",
"deprecated": null
Expand Down
1 change: 1 addition & 0 deletions cln-grpc/proto/node.proto

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions cln-grpc/src/convert.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions cln-rpc/src/model.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 67 additions & 7 deletions contrib/msggen/msggen/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -7458,11 +7458,24 @@
"$schema": "../rpc-schema-draft.json",
"type": "object",
"rpc": "currencyrate",
"title": "Command to determine a reasonable rate of conversion for a given currency",
"title": "Command to determine a reasonable BTC exchange rate for a given currency",
"added": "v26.04",
"description": [
"The **currencyrate** RPC command provides the conversion of one BTC into the given currency",
"It uses the median of the available exchange-rate sources for the requested currency."
"The **currencyrate** RPC command returns the exchange rate of one BTC in the given currency.",
"Rates are rounded to 3 decimal places as specified by ISO 4217.",
"",
"Without the optional *source* argument the command returns the median rate across all",
"configured exchange-rate sources.",
"",
"With *source* specified, the rate reported by that specific source is returned.",
"The list of active source names can be obtained via **listcurrencyrates**.",
"",
"To add or disable sources see the *currencyrate-add-source* and *currencyrate-disable-source*",
"options in **lightningd-config(5)**.",
"",
"EXAMPLE USAGE:",
"Get the median USD rate: `lightning-cli currencyrate USD`",
"Get the Binance USD rate: `lightning-cli currencyrate USD binance`"
],
"request": {
"required": [
Expand All @@ -7473,8 +7486,18 @@
"currency": {
"type": "string",
"description": [
"The ISO-4217 currency code (e.g. USD) to convert to.",
"The plugin normalizes this value to uppercase before querying sources."
"The ISO 4217 currency code (e.g. USD) to convert to.",
"The plugin normalises this value to uppercase before querying sources."
]
},
"source": {
"added": "v26.06",
Copy link
Copy Markdown
Collaborator

@daywalker90 daywalker90 Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is scheduled for v26.06 so any fields added after currencyrate's v26.04 must have an added annotation. I went ahead and did that. Whenever schema files change we need to commit the generated files from uv run make gen as well. I also did that in another commit.

"type": "string",
"description": [
"The name of a specific exchange-rate source to query.",
"If omitted, the median across all available sources is returned.",
"An error is returned if the source name is not recognised or has no",
"cached data for the requested currency."
]
}
}
Expand All @@ -7488,7 +7511,9 @@
"rate": {
"type": "number",
"description": [
"The median value of one BTC, computed using the median result from the available sources."
"The number of currency units per 1 BTC, rounded to 3 decimal places (ISO 4217).",
"When *source* is omitted this is the median across all available sources.",
"When *source* is specified this is the rate reported by that source."
]
}
}
Expand All @@ -7497,10 +7522,45 @@
"daywalker90 is mainly responsible."
],
"see_also": [
"lightning-listcurrencyrates(7)"
"lightning-listcurrencyrates(7)",
"lightning-currencyconvert(7)",
"lightningd-config(5)"
],
"resources": [
"Main web site: [https://github.com/ElementsProject/lightning](https://github.com/ElementsProject/lightning)"
],
"examples": [
{
"description": [
"Get the median BTC/USD rate across all configured sources:"
],
"request": {
"id": "example:currencyrate#1",
"method": "currencyrate",
"params": {
"currency": "USD"
}
},
"response": {
"rate": 67889.825
}
},
{
"description": [
"Get the BTC/USD rate from the Binance source only:"
],
"request": {
"id": "example:currencyrate#2",
"method": "currencyrate",
"params": {
"currency": "USD",
"source": "binance"
}
},
"response": {
"rate": 67931.9
}
}
]
},
"datastore.json": {
Expand Down
268 changes: 134 additions & 134 deletions contrib/pyln-grpc-proto/pyln/grpc/node_pb2.py

Large diffs are not rendered by default.

74 changes: 67 additions & 7 deletions doc/schemas/currencyrate.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,24 @@
"$schema": "../rpc-schema-draft.json",
"type": "object",
"rpc": "currencyrate",
"title": "Command to determine a reasonable rate of conversion for a given currency",
"title": "Command to determine a reasonable BTC exchange rate for a given currency",
"added": "v26.04",
"description": [
"The **currencyrate** RPC command provides the conversion of one BTC into the given currency",
"It uses the median of the available exchange-rate sources for the requested currency."
"The **currencyrate** RPC command returns the exchange rate of one BTC in the given currency.",
"Rates are rounded to 3 decimal places as specified by ISO 4217.",
"",
"Without the optional *source* argument the command returns the median rate across all",
"configured exchange-rate sources.",
"",
"With *source* specified, the rate reported by that specific source is returned.",
"The list of active source names can be obtained via **listcurrencyrates**.",
"",
"To add or disable sources see the *currencyrate-add-source* and *currencyrate-disable-source*",
"options in **lightningd-config(5)**.",
"",
"EXAMPLE USAGE:",
"Get the median USD rate: `lightning-cli currencyrate USD`",
"Get the Binance USD rate: `lightning-cli currencyrate USD binance`"
],
"request": {
"required": [
Expand All @@ -17,8 +30,18 @@
"currency": {
"type": "string",
"description": [
"The ISO-4217 currency code (e.g. USD) to convert to.",
"The plugin normalizes this value to uppercase before querying sources."
"The ISO 4217 currency code (e.g. USD) to convert to.",
"The plugin normalises this value to uppercase before querying sources."
]
},
"source": {
"added": "v26.06",
"type": "string",
"description": [
"The name of a specific exchange-rate source to query.",
"If omitted, the median across all available sources is returned.",
"An error is returned if the source name is not recognised or has no",
"cached data for the requested currency."
]
}
}
Expand All @@ -32,7 +55,9 @@
"rate": {
"type": "number",
"description": [
"The median value of one BTC, computed using the median result from the available sources."
"The number of currency units per 1 BTC, rounded to 3 decimal places (ISO 4217).",
"When *source* is omitted this is the median across all available sources.",
"When *source* is specified this is the rate reported by that source."
]
}
}
Expand All @@ -41,9 +66,44 @@
"daywalker90 is mainly responsible."
],
"see_also": [
"lightning-listcurrencyrates(7)"
"lightning-listcurrencyrates(7)",
"lightning-currencyconvert(7)",
"lightningd-config(5)"
],
"resources": [
"Main web site: [https://github.com/ElementsProject/lightning](https://github.com/ElementsProject/lightning)"
],
"examples": [
{
"description": [
"Get the median BTC/USD rate across all configured sources:"
],
"request": {
"id": "example:currencyrate#1",
"method": "currencyrate",
"params": {
"currency": "USD"
}
},
"response": {
"rate": 67889.825
}
},
{
"description": [
"Get the BTC/USD rate from the Binance source only:"
],
"request": {
"id": "example:currencyrate#2",
"method": "currencyrate",
"params": {
"currency": "USD",
"source": "binance"
}
},
"response": {
"rate": 67931.9
}
}
]
}
54 changes: 42 additions & 12 deletions plugins/currencyrate-plugin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ mod oracle;
const DEFAULT_PROXY_PORT: u16 = 9050;
const MSAT_PER_BTC: f64 = 1e11;

fn round_to_3dp(price: f64) -> f64 {
format!("{:.3}", price).parse::<f64>().unwrap_or(price)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of moving from f64 to a string and then back again to f64, it would be better to do some purely mathematical operation.
eg. output = (input*1000.0).round() / 1000.0;

Copy link
Copy Markdown
Collaborator

@daywalker90 daywalker90 Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to always get 3 decimal places you must return a String:

fn round_to_3dp(price: f64) -> String {
    format!("{:.3}", (price * 1000.0).round() / 1000.0)
}

Copy link
Copy Markdown
Collaborator Author

@enaples enaples Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is not that reliable. See here the comment for very large number. That's why I opted for the f64 --> String --> f64 solution.

I need to return again f64 since the price is used to compute the median, so it is better to return f64 again in my opinion.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, the linked issue said to always have 3 decimal places not at most 3. So this is fine then if we can live with always rounding toward 0.

}

#[derive(Debug, Clone)]
pub struct SourceResult {
pub name: String,
Expand Down Expand Up @@ -104,6 +108,12 @@ median from currencyrates results",
async fn currencyconvert(plugin: Plugin<PluginState>, args: Value) -> Result<Value, anyhow::Error> {
let (amount, currency) = match args {
Value::Array(values) => {
if values.len() > 2 {
return Err(anyhow!(
"Too many arguments: expected at most 2 (amount currency), got {}",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: change this to expected 2.

values.len()
));
}
let amount = values
.first()
.ok_or_else(|| anyhow!("Missing amount"))?
Expand Down Expand Up @@ -146,42 +156,62 @@ async fn currencyconvert(plugin: Plugin<PluginState>, args: Value) -> Result<Val
}

async fn currencyrate(plugin: Plugin<PluginState>, args: Value) -> Result<Value, anyhow::Error> {
let currency = match args {
let (currency, source) = match args {
Value::Array(values) => {
if values.len() > 2 {
return Err(anyhow!(
"Too many arguments: expected at most 2 (currency [source]), got {}",
values.len()
));
}
let currency = values
.first()
.ok_or_else(|| anyhow!("Missing currency"))?
.as_str()
.ok_or_else(|| anyhow!("currency must be a string"))?
.to_owned();
currency.to_uppercase()
.to_uppercase();
let source = values.get(1).and_then(|v| v.as_str()).map(str::to_owned);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do allow sources to be named as numbers only and this would then cause currencyrate to silently ignore the sourrce and return a median result. I suggest changing this to:

let source = values.get(1).and_then(|v| {
                v.as_str()
                    .map(str::to_owned)
                    .or_else(|| v.as_number().map(std::string::ToString::to_string))
            });

(currency, source)
}
Value::Object(map) => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should then also do the same check for object based queries here aswell.

let currency = map
.get("currency")
.ok_or_else(|| anyhow!("Missing currency"))?
.as_str()
.ok_or_else(|| anyhow!("currency must be a string"))?
.to_owned();
currency.to_uppercase()
.to_uppercase();
let source = map.get("source").and_then(|v| v.as_str()).map(str::to_owned);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above:

let source = map.get("source").and_then(|v| {
                v.as_str()
                    .map(str::to_owned)
                    .or_else(|| v.as_number().map(std::string::ToString::to_string))
            });

(currency, source)
}
_ => return Err(anyhow!("Arguments must be an array or dictionary")),
};

let oracle = plugin.state().oracle.lock().await;
oracle.currency_requested(&currency).await;

match oracle.get_median_rate(&currency).await {
Ok(result) => Ok(json!({
"rate": result,
})),
Err(e) => Err(anyhow!("Error converting currency: {e}")),
}
let rate = match source {
Some(source_name) => oracle
.get_source_rate(&currency, &source_name)
.await
.map_err(|e| anyhow!("Error getting rate from source: {e}"))?,
None => oracle
.get_median_rate(&currency)
.await
.map_err(|e| anyhow!("Error getting median rate: {e}"))?,
};

Ok(json!({ "rate": round_to_3dp(rate) }))
}

async fn listcurrencyrates(plugin: Plugin<PluginState>, args: Value) -> Result<Value, anyhow::Error> {
let currency = match args {
Value::Array(values) => {
if values.len() > 1 {
return Err(anyhow!(
"Too many arguments: expected at most 1 (currency), got {}",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "expected 1"

values.len()
));
}
let currency = values
.first()
.ok_or_else(|| anyhow!("Missing currency"))?
Expand Down Expand Up @@ -212,7 +242,7 @@ async fn listcurrencyrates(plugin: Plugin<PluginState>, args: Value) -> Result<V
.map(|source_result| {
json!({
"source": source_result.name,
"amount": source_result.price,
"amount": round_to_3dp(source_result.price),
})
})
.collect::<Vec<_>>();
Expand Down
Loading
Loading