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
73 changes: 66 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,17 @@
"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": {
"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 +54,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 +65,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)
}

#[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 {}",
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);
(currency, source)
}
Value::Object(map) => {
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);
(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 {}",
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
42 changes: 42 additions & 0 deletions plugins/currencyrate-plugin/src/oracle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading