currencyrate: round to the third digit and adding source argument to `currencyrate` command
What changed, and why it matters
This commit updates the currency-rate plugin in Core Lightning. It adds a new optional 'source' argument to the `currencyrate` command so users can pick a specific price source, rounds displayed rates to three decimal places, and adds argument-count checks to several commands to reject extra parameters. These are minor functional and hardening changes; there is no clear security vulnerability being fixed.
No security action required; treat as a routine feature/hardening update. Reviewers may verify the new `get_source_rate` TTL and error paths behave as documented.
Security signals we found
Input validation: argument-count limits added to three RPC methods
New oracle helper validates source name, currency support, and cache TTL before returning a rate
Rounding change is a presentation-layer change, not a security fix
Evidence from the diff
The patch modifies plugins/currencyrate-plugin/src/main.rs and plugins/currencyrate-plugin/src/oracle.rs. It introduces round_to_3dp() to format prices to three decimal places, adds get_source_rate() in the oracle to retrieve a single source’s cached rate with validation and TTL checks, and wires an optional source parameter into the currencyrate RPC. It also adds len() > N guards on currencyconvert, currencyrate, and listcurrencyrates to reject excess positional or named arguments. No memory-safety, cryptographic, or authorization issues are evident in the diff.
Changed components
plugins/currencyrate-plugin/src/main.rsplugins/currencyrate-plugin/src/oracle.rscurrencyrate RPC commandcurrencyconvert RPC commandlistcurrencyrates RPC commandInspect captured patch +110 / −12
diff --git a/plugins/currencyrate-plugin/src/main.rs b/plugins/currencyrate-plugin/src/main.rs
index e03d0bbd..8057dfd0 100644
--- a/plugins/currencyrate-plugin/src/main.rs
+++ b/plugins/currencyrate-plugin/src/main.rs
@@ -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)
+}
+
#[derive(Debug, Clone)]
pub struct SourceResult {
pub name: String,
@@ -115,6 +119,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 2 arguments, got {}",
+ values.len()
+ ));
+ }
let amount = values
.first()
.ok_or_else(|| anyhow!("Missing amount"))?
@@ -129,6 +139,12 @@ async fn currencyconvert(plugin: Plugin<PluginState>, args: Value) -> Result<Val
(amount, currency.to_uppercase())
}
Value::Object(map) => {
+ if map.len() > 2 {
+ return Err(anyhow!(
+ "Too many arguments: Expected 2 arguments, got {}",
+ map.len()
+ ));
+ }
let amount = map
.get("amount")
.ok_or_else(|| anyhow!("Missing amount"))?
@@ -157,24 +173,46 @@ 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 arguments, 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)
+ .or_else(|| v.as_number().map(std::string::ToString::to_string))
+ });
+ (currency, source)
}
Value::Object(map) => {
+ if map.len() > 2 {
+ return Err(anyhow!(
+ "Too many arguments: Expected at most 2 arguments, got {}",
+ map.len()
+ ));
+ }
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)
+ .or_else(|| v.as_number().map(std::string::ToString::to_string))
+ });
+ (currency, source)
}
_ => return Err(anyhow!("Arguments must be an array or dictionary")),
};
@@ -182,12 +220,18 @@ async fn currencyrate(plugin: Plugin<PluginState>, args: Value) -> Result<Value,
let oracle = plugin.state().oracle.lock().await;
oracle.currency_requested(¤cy).await;
- match oracle.get_median_rate(¤cy).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(¤cy, &source_name)
+ .await
+ .map_err(|e| anyhow!("Error getting rate from source: {e}"))?,
+ None => oracle
+ .get_median_rate(¤cy)
+ .await
+ .map_err(|e| anyhow!("Error getting median rate: {e}"))?,
+ };
+
+ Ok(json!({ "rate": round_to_3dp(rate) }))
}
async fn listcurrencyrates(
@@ -196,6 +240,12 @@ async fn listcurrencyrates(
) -> Result<Value, anyhow::Error> {
let currency = match args {
Value::Array(values) => {
+ if values.len() > 1 {
+ return Err(anyhow!(
+ "Too many arguments: Expected 1 argument, got {}",
+ values.len()
+ ));
+ }
let currency = values
.first()
.ok_or_else(|| anyhow!("Missing currency"))?
@@ -205,6 +255,12 @@ async fn listcurrencyrates(
currency.to_uppercase()
}
Value::Object(map) => {
+ if map.len() > 1 {
+ return Err(anyhow!(
+ "Too many arguments: Expected 1 argument, got {}",
+ map.len()
+ ));
+ }
let currency = map
.get("currency")
.ok_or_else(|| anyhow!("Missing currency"))?
@@ -226,7 +282,7 @@ async fn listcurrencyrates(
.map(|source_result| {
json!({
"source": source_result.name,
- "amount": source_result.price,
+ "amount": round_to_3dp(source_result.price),
})
})
.collect::<Vec<_>>();
diff --git a/plugins/currencyrate-plugin/src/oracle.rs b/plugins/currencyrate-plugin/src/oracle.rs
index 7145c4ff..5e53a140 100644
--- a/plugins/currencyrate-plugin/src/oracle.rs
+++ b/plugins/currencyrate-plugin/src/oracle.rs
@@ -523,6 +523,48 @@ impl BtcPriceOracle {
}
});
}
+
+ pub async fn get_source_rate(
+ &self,
+ currency: &str,
+ source_name: &str,
+ ) -> Result<f64, anyhow::Error> {
+ self.refresh_currency(currency).await?;
+
+ let inner = self.inner.lock().await;
+
+ // Give a helpful error if the source name is unknown entirely
+ if !inner.sources.contains_key(source_name) {
+ let available = inner
+ .sources
+ .keys()
+ .cloned()
+ .collect::<Vec<_>>()
+ .join(", ");
+ return Err(anyhow!(
+ "Unknown source `{source_name}`. Available sources: {available}"
+ ));
+ }
+
+ let currency_cache = inner
+ .currencies
+ .get(currency)
+ .ok_or_else(|| anyhow!("No rates available for `{currency}`"))?;
+
+ let price_cache = currency_cache
+ .prices
+ .get(source_name)
+ .ok_or_else(|| anyhow!(
+ "Source `{source_name}` has no data for `{currency}`. \
+ The source may not support this currency or is currently backing off."
+ ))?;
+
+ if price_cache.timestamp + SERVE_TTL <= Instant::now() {
+ return Err(anyhow!("Cached rate from `{source_name}` is expired"));
+ }
+
+ Ok(price_cache.price)
+ }
}
fn get_median(source_results: Vec<SourceResult>) -> f64 {
Why this scored 20/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.