cln-currencyrate: switch algorithm to a more lazy one
What changed, and why it matters
This commit rewrites the currency-rate plugin in Core Lightning so it no longer fetches fresh exchange rates on every request. Instead, it starts a background updater that keeps a longer-lived cache and only consults extra sources if prices from different sources drift too far apart. The change also normalizes currency codes to uppercase, rejects zero prices, adds source failure backoff, and stops background tasks when a currency hasn't been requested recently. There is no explicit security bug being fixed; it is a reliability and resource-usage improvement.
Treat as a routine reliability/defensive-hardening patch. Reviewers should verify that the background task correctly terminates (no task leak), that the 1% drift threshold is appropriate for the target currencies, and that the all-sources backoff reset cannot be abused by a transient network blip to hammer APIs. No urgent security deployment is indicated by the commit alone.
Security signals we found
Rejection of zero-price responses prevents a source from causing a zero or wildly wrong conversion result.
Currency codes are normalized to uppercase, reducing the chance of cache misses or source lookups due to case differences.
Source backoff and all-sources-backed-off reset reduce denial-of-service amplification against external APIs and self-DoS from repeated failing fetches.
Background refresh with drift-based cross-source validation is a defense-in-depth measure against a single compromised or erroneous price source.
No explicit vulnerability, CVE, or security advisory is mentioned in the commit or supplied references.
Evidence from the diff
The patch replaces a synchronous per-request fetch model with a lazy background refresh model in plugins/currencyrate-plugin. Key changes: (1) BtcPriceOracle now owns an Arc
Changed components
plugins/currencyrate-plugin/src/main.rsplugins/currencyrate-plugin/src/oracle.rstests/test_currencyrate.pyInspect captured patch +410 / −141
diff --git a/plugins/currencyrate-plugin/src/main.rs b/plugins/currencyrate-plugin/src/main.rs
index 7c194806..e1d8c2bf 100644
--- a/plugins/currencyrate-plugin/src/main.rs
+++ b/plugins/currencyrate-plugin/src/main.rs
@@ -3,28 +3,19 @@ use cln_plugin::options::StringArrayConfigOption;
use cln_plugin::{Builder, ConfiguredPlugin, Plugin, RpcMethodBuilder};
use cln_rpc::ClnRpc;
use serde_json::{json, Value};
-use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
+use std::vec;
use tokio::sync::Mutex;
use crate::oracle::{BtcPriceOracle, Source};
mod oracle;
-pub const CONVERT_SOURCES_COUNT: usize = 3;
-pub const SOURCE_TIMEOUT_SECS: u64 = 5;
-const CACHE_DURATIONS_SECS: u64 = 10;
const DEFAULT_PROXY_PORT: u16 = 9050;
-#[derive(Debug, Clone)]
-pub struct CachedPrice {
- data: HashMap<String, Vec<SourceResult>>,
- timestamp: u64,
-}
-
#[derive(Debug, Clone)]
pub struct SourceResult {
pub name: String,
@@ -41,7 +32,7 @@ async fn main() -> Result<(), anyhow::Error> {
log_panics::init();
std::env::set_var(
"CLN_PLUGIN_LOG",
- "cln_plugin=info,cln_rpc=info,cln-currencyrate=debug,warn",
+ "cln_plugin=info,cln_rpc=info,cln_currencyrate=debug,warn",
);
let _ = rustls::crypto::ring::default_provider().install_default();
@@ -88,7 +79,7 @@ median from currencyrates results",
Err(e) => return plugin.disable(&e.to_string()).await,
};
- let price_oracle = match BtcPriceOracle::new(CACHE_DURATIONS_SECS, proxy, sources) {
+ let price_oracle = match BtcPriceOracle::new(proxy, sources) {
Ok(o) => o,
Err(e) => return plugin.disable(&e.to_string()).await,
};
@@ -116,7 +107,7 @@ async fn currencyconvert(plugin: Plugin<PluginState>, args: Value) -> Result<Val
.as_str()
.ok_or_else(|| anyhow!("currency must be a string"))?
.to_owned();
- (amount, currency)
+ (amount, currency.to_uppercase())
}
Value::Object(map) => {
let amount = map
@@ -130,12 +121,13 @@ async fn currencyconvert(plugin: Plugin<PluginState>, args: Value) -> Result<Val
.as_str()
.ok_or_else(|| anyhow!("currency must be a string"))?
.to_owned();
- (amount, currency)
+ (amount, currency.to_uppercase())
}
_ => return Err(anyhow!("Arguments must be an array or dictionary")),
};
- let mut oracle = plugin.state().oracle.lock().await;
+ let oracle = plugin.state().oracle.lock().await;
+ oracle.currency_requested(¤cy).await;
match oracle.convert(amount, ¤cy).await {
Ok(result) => Ok(json!({
@@ -154,7 +146,7 @@ async fn currencyrates(plugin: Plugin<PluginState>, args: Value) -> Result<Value
.as_str()
.ok_or_else(|| anyhow!("currency must be a string"))?
.to_owned();
- currency
+ currency.to_uppercase()
}
Value::Object(map) => {
let currency = map
@@ -163,16 +155,15 @@ async fn currencyrates(plugin: Plugin<PluginState>, args: Value) -> Result<Value
.as_str()
.ok_or_else(|| anyhow!("currency must be a string"))?
.to_owned();
- currency
+ currency.to_uppercase()
}
_ => return Err(anyhow!("Arguments must be an array or dictionary")),
};
- let mut oracle = plugin.state().oracle.lock().await;
+ let oracle = plugin.state().oracle.lock().await;
+ oracle.currency_requested(¤cy).await;
- let sources_count = oracle.source_count();
-
- match oracle.get_rates(¤cy, sources_count).await {
+ match oracle.get_all_rates(¤cy).await {
Ok(result) => {
let mut map = serde_json::Map::new();
for source_result in result {
@@ -248,6 +239,10 @@ fn gather_sources(
}
}
+ if result.is_empty() {
+ return Err(anyhow!("No sources configured"));
+ }
+
Ok(result)
}
diff --git a/plugins/currencyrate-plugin/src/oracle.rs b/plugins/currencyrate-plugin/src/oracle.rs
index 9bdb3b2e..0a68d672 100644
--- a/plugins/currencyrate-plugin/src/oracle.rs
+++ b/plugins/currencyrate-plugin/src/oracle.rs
@@ -1,16 +1,26 @@
use anyhow::anyhow;
use futures::future::join_all;
-use rand::seq::IndexedRandom;
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use reqwest::{Client, Proxy};
use serde_json::Value;
+use std::cmp::Reverse;
use std::collections::HashMap;
use std::error::Error;
use std::net::SocketAddr;
-use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+use tokio::sync::Mutex;
-use crate::{CachedPrice, SourceResult, CONVERT_SOURCES_COUNT, SOURCE_TIMEOUT_SECS};
+use crate::SourceResult;
+const SOURCE_TIMEOUT_SECS: Duration = Duration::from_secs(10);
+const SERVE_TTL: Duration = Duration::from_secs(3_600);
+const DRIFT_THRESHOLD: f64 = 0.01;
+
+const INITIAL_BACKOFF: Duration = Duration::from_secs(30);
+const MAX_BACKOFF: Duration = Duration::from_secs(3_600);
+
+#[derive(Debug, Clone)]
pub struct Source {
name: String,
url_template: String,
@@ -103,7 +113,16 @@ impl Source {
_ => return Err(anyhow!("Price is invalid json type")),
};
- log::debug!(
+ if price == 0.0 {
+ log::warn!("{} returned 0.0 as price for {}", self.name, currency);
+ return Err(anyhow!(
+ "{} returned 0.0 as price for {}",
+ self.name,
+ currency
+ ));
+ }
+
+ log::info!(
"Fetched price in {}ms from {}: {:.2} {currency}",
now.elapsed().as_millis(),
self.name,
@@ -114,26 +133,121 @@ impl Source {
}
}
+struct SourceHealth {
+ source: Source,
+ failures: u32,
+ backoff_until: Instant,
+}
+
+impl SourceHealth {
+ fn new(source: Source) -> Self {
+ Self {
+ source,
+ failures: 0,
+ backoff_until: Instant::now(),
+ }
+ }
+
+ fn mark_success(&mut self) {
+ self.failures = 0;
+ self.backoff_until = Instant::now();
+ }
+
+ fn mark_failure(&mut self) {
+ self.failures += 1;
+ let delay = INITIAL_BACKOFF * 2u32.pow(self.failures.min(10));
+ self.backoff_until = Instant::now() + delay.min(MAX_BACKOFF);
+ }
+}
+
+#[derive(Debug)]
+struct PriceCache {
+ price: f64,
+ timestamp: Instant,
+}
+struct CurrencyCache {
+ prices: HashMap<String, PriceCache>,
+ last_request: Instant,
+}
+
+impl CurrencyCache {
+ fn new() -> Self {
+ Self {
+ prices: HashMap::new(),
+ last_request: Instant::now(),
+ }
+ }
+
+ fn latest_fresh_price(&self) -> Option<SourceResult> {
+ self.prices
+ .iter()
+ .filter(|(_, p)| p.timestamp + SERVE_TTL > Instant::now())
+ .max_by_key(|(_, p)| p.timestamp)
+ .map(|(n, p)| SourceResult {
+ name: n.clone(),
+ price: p.price,
+ })
+ }
+
+ fn is_drift_ok(&self) -> bool {
+ let mut cache_sorted_by_recency: Vec<(&String, &PriceCache)> = self.prices.iter().collect();
+ cache_sorted_by_recency.sort_by_key(|(_, p)| Reverse(p.timestamp));
+ if cache_sorted_by_recency.len() == 1 {
+ return true;
+ }
+
+ let latest_price = cache_sorted_by_recency.first().unwrap().1;
+ let second_latest_price = cache_sorted_by_recency.get(1).unwrap().1;
+
+ let relative_drift =
+ f64::abs((latest_price.price - second_latest_price.price) / second_latest_price.price);
+
+ if relative_drift > DRIFT_THRESHOLD {
+ return false;
+ }
+
+ true
+ }
+
+ fn currency_requested(&mut self) {
+ self.last_request = Instant::now();
+ }
+
+ fn is_currency_still_desired(&self) -> bool {
+ self.last_request + SERVE_TTL * 3 > Instant::now()
+ }
+}
+
+struct OracleInner {
+ sources: HashMap<String, SourceHealth>,
+ currencies: HashMap<String, CurrencyCache>,
+}
+
+impl OracleInner {
+ fn reset_backoff_if_needed(&mut self, now: Instant) {
+ // if all sources are backed off, reset them all to avoid indefinite starvation
+ if self.sources.values().all(|sh| sh.backoff_until >= now) {
+ for sh in self.sources.values_mut() {
+ sh.mark_success();
+ }
+ }
+ }
+}
+
pub struct BtcPriceOracle {
- sources: Vec<Source>,
- cache: Option<CachedPrice>,
- cache_duration_s: u64,
+ inner: Arc<Mutex<OracleInner>>,
client: Client,
}
impl BtcPriceOracle {
- pub fn new(
- cache_duration_s: u64,
- tor_proxy: Option<SocketAddr>,
- sources: Vec<Source>,
- ) -> Result<Self, anyhow::Error> {
+ pub fn new(tor_proxy: Option<SocketAddr>, sources: Vec<Source>) -> Result<Self, anyhow::Error> {
let mut headers = HeaderMap::new();
- headers.insert(USER_AGENT, HeaderValue::from_static("currencyrate-plugin"));
+ headers.insert(USER_AGENT, HeaderValue::from_static("cln-currencyrate"));
let mut client = Client::builder()
.default_headers(headers)
.tls_backend_rustls()
- .timeout(Duration::from_secs(SOURCE_TIMEOUT_SECS))
+ .timeout(SOURCE_TIMEOUT_SECS)
.pool_max_idle_per_host(5);
if let Some(tp) = tor_proxy {
@@ -145,124 +259,277 @@ impl BtcPriceOracle {
let client = client.build()?;
+ let mut map = HashMap::new();
+ for s in sources {
+ map.insert(s.name().to_owned(), SourceHealth::new(s));
+ }
+
Ok(Self {
- sources,
- cache: None,
- cache_duration_s,
+ inner: Arc::new(Mutex::new(OracleInner {
+ sources: map,
+ currencies: HashMap::new(),
+ })),
client,
})
}
- pub async fn get_rates(
- &mut self,
- currency: &str,
- num_sources: usize,
- ) -> Result<Vec<SourceResult>, anyhow::Error> {
- let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
-
- if let Some(cache) = &self.cache {
- if now - cache.timestamp < self.cache_duration_s {
- if let Some(price) = cache.data.get(currency) {
- log::debug!("Using cached rates for {currency}");
- return Ok(price.clone());
- }
- }
+ pub async fn currency_requested(&self, currency: &str) {
+ let mut inner = self.inner.lock().await;
+ if let Some(cache) = inner.currencies.get_mut(currency) {
+ cache.currency_requested();
}
+ }
+
+ pub async fn get_all_rates(&self, currency: &str) -> Result<Vec<SourceResult>, anyhow::Error> {
+ self.refresh_currency(currency).await?;
- let mut source_results = Vec::with_capacity(self.sources.len());
+ let results = {
+ let mut inner = self.inner.lock().await;
+ let cache = inner
+ .currencies
+ .entry(currency.to_owned())
+ .or_insert_with(CurrencyCache::new);
- let sources: Vec<&Source> = {
- let mut rng = &mut rand::rng();
- self.sources
- .choose_multiple(&mut rng, num_sources)
- .collect()
+ cache
+ .prices
+ .iter()
+ .filter(|(_, price_cache)| price_cache.timestamp + SERVE_TTL > Instant::now())
+ .map(|(name, price)| SourceResult {
+ name: name.clone(),
+ price: price.price,
+ })
+ .collect::<Vec<_>>()
};
- let futures = sources.iter().map(|source| {
- let client = &self.client;
- async move { (source.name(), source.get_rate(client, currency).await) }
- });
+ if results.is_empty() {
+ return Err(anyhow::anyhow!(
+ "no results for `{currency}`, is the currency supported? Check the logs!"
+ ));
+ }
- let results = join_all(futures).await;
+ Ok(results)
+ }
- for (name, result) in results {
- match result {
- Ok(price) => {
- source_results.push(SourceResult {
- price,
- name: name.to_owned(),
- });
- }
- Err(e) => {
- log::warn!("Error fetching from {name}: {e}");
- }
+ pub async fn convert(&self, amount: f64, currency: &str) -> Result<u64, anyhow::Error> {
+ let inner = self.inner.lock().await;
+ let source_results = if let Some(currency_cache) = inner.currencies.get(currency) {
+ if let Some(price) = currency_cache.latest_fresh_price() {
+ vec![price]
+ } else {
+ log::warn!("background task failed to keep currency `{currency}` up to date");
+ drop(inner);
+ self.get_all_rates(currency).await?
}
+ } else {
+ drop(inner);
+ self.get_all_rates(currency).await?
+ };
+
+ let median_rate = get_median_rate(source_results);
+ Ok((amount * median_rate).round() as u64)
+ }
+
+ async fn refresh_currency(&self, currency: &str) -> Result<(), anyhow::Error> {
+ let mut inner = self.inner.lock().await;
+ let now = Instant::now();
+ let mut start_background_refresh = false;
+
+ let mut source_candidates: Vec<String> = inner
+ .sources
+ .iter()
+ .filter(|(_, s)| now >= s.backoff_until)
+ .map(|(name, _)| name.clone())
+ .collect();
+
+ if source_candidates.is_empty() {
+ log::warn!("all sources have failed recently, trying them immediately again");
+ source_candidates = inner.sources.keys().cloned().collect();
}
- if source_results.len() < num_sources {
- let remaining_sources: Vec<&Source> = self
- .sources
- .iter()
- .filter(|s| !sources.contains(s))
- .collect();
- for source in remaining_sources {
- if source_results.len() >= num_sources {
- break;
- }
- match source.get_rate(&self.client, currency).await {
+ let currency_cache = if let Some(c) = inner.currencies.get(currency) {
+ c
+ } else {
+ inner
+ .currencies
+ .insert(currency.to_owned(), CurrencyCache::new());
+ start_background_refresh = true;
+ inner.currencies.get(currency).unwrap()
+ };
+ source_candidates.retain(|c| {
+ currency_cache
+ .prices
+ .get(c)
+ .is_none_or(|p| p.timestamp + SERVE_TTL <= now)
+ });
+
+ drop(inner);
+
+ let futures = source_candidates.iter().map(|source_name| {
+ let inner = self.inner.clone();
+ let client = self.client.clone();
+ let source_name = source_name.clone();
+
+ async move {
+ let source = {
+ let inner = inner.lock().await;
+ inner.sources.get(&source_name).unwrap().source.clone()
+ };
+
+ let rate_result = source.get_rate(&client, currency).await;
+
+ let mut inner = inner.lock().await;
+
+ let source_health = inner.sources.get_mut(&source_name).unwrap();
+
+ match rate_result {
Ok(price) => {
- source_results.push(SourceResult {
- price,
- name: source.name().to_owned(),
- });
+ source_health.mark_success();
+
+ let cache = inner.currencies.get_mut(currency).unwrap();
+ cache.prices.insert(
+ source_name,
+ PriceCache {
+ price,
+ timestamp: Instant::now(),
+ },
+ );
}
+
Err(e) => {
- log::warn!("Error fetching from {}: {e}", source.name);
+ log::warn!("failed to get `{currency}` rate from {source_name}: {e}");
+ source_health.mark_failure();
}
}
}
- }
+ });
- if source_results.is_empty() {
- return Err(anyhow!(
- "No sources configured or all failed, check the logs."
- ));
- }
+ join_all(futures).await;
- source_results.sort_by(|a, b| a.price.partial_cmp(&b.price).unwrap());
+ let had_any_success =
+ if let Some(currency_cache) = self.inner.lock().await.currencies.get(currency) {
+ !currency_cache.prices.is_empty()
+ } else {
+ false
+ };
- if let Some(cache) = &mut self.cache {
- cache
- .data
- .insert(currency.to_string(), source_results.clone());
- cache.timestamp = now;
- } else {
- let mut data = HashMap::new();
- data.insert(currency.to_string(), source_results.clone());
- self.cache = Some(CachedPrice {
- data,
- timestamp: now,
- });
+ if had_any_success && start_background_refresh {
+ self.background_refresh(currency);
}
- Ok(source_results)
- }
- pub async fn convert(&mut self, amount: f64, currency: &str) -> Result<u64, anyhow::Error> {
- let source_results = self.get_rates(currency, CONVERT_SOURCES_COUNT).await?;
- let median_rate = get_median_rate(source_results);
- Ok((amount * median_rate).round() as u64)
+ Ok(())
}
- pub fn source_count(&self) -> usize {
- self.sources.len()
+ fn background_refresh(&self, currency: &str) {
+ let inner = self.inner.clone();
+ let client = self.client.clone();
+ let currency = currency.to_owned();
+ tokio::spawn(async move {
+ tokio::time::sleep(SOURCE_TIMEOUT_SECS * 5).await;
+ loop {
+ let mut inner = inner.lock().await;
+ let now = Instant::now();
+ inner.reset_backoff_if_needed(now);
+ let available_sources: Vec<String> = inner
+ .sources
+ .iter()
+ .filter(|(_, source_health)| source_health.backoff_until < now)
+ .map(|(name, _)| name)
+ .cloned()
+ .collect();
+
+ let prices = inner
+ .currencies
+ .get(¤cy)
+ .map(|currency_cache| ¤cy_cache.prices);
+
+ let mut sources_by_staleness: Vec<(String, Option<Instant>)> = available_sources
+ .into_iter()
+ .map(|name| {
+ let last_fetch = prices
+ .and_then(|prices| prices.get(&name))
+ .map(|price_cache| price_cache.timestamp);
+ (name, last_fetch)
+ })
+ .collect();
+
+ sources_by_staleness.sort_by_key(|(_, s)| *s);
+
+ let stale_cutoff = now - SERVE_TTL + 2 * SOURCE_TIMEOUT_SECS;
+ if sources_by_staleness
+ .last()
+ .and_then(|(_, timestamp)| *timestamp)
+ .is_some_and(|timestamp| timestamp > stale_cutoff)
+ {
+ sources_by_staleness.clear();
+ }
+
+ log::trace!(
+ "sources_by_staleness: {}",
+ sources_by_staleness
+ .iter()
+ .map(|(n, p)| format!(
+ "{n}:{}",
+ p.map(|f| f.elapsed().as_secs()).unwrap_or(0)
+ ))
+ .collect::<Vec<String>>()
+ .join(", ")
+ );
+
+ for (name, _) in sources_by_staleness {
+ let source_health = inner.sources.get_mut(&name).unwrap();
+ match source_health.source.get_rate(&client, ¤cy).await {
+ Ok(price) => {
+ source_health.mark_success();
+
+ let currency_cache = inner.currencies.get_mut(¤cy).unwrap();
+ currency_cache.prices.insert(
+ name.clone(),
+ PriceCache {
+ price,
+ timestamp: Instant::now(),
+ },
+ );
+
+ if currency_cache.is_drift_ok() {
+ break;
+ }
+ }
+ Err(e) => {
+ log::warn!("failed to get `{currency}` rate from {name}: {e}");
+ source_health.mark_failure();
+ }
+ }
+ }
+
+ if !inner
+ .currencies
+ .get(¤cy)
+ .is_some_and(CurrencyCache::is_currency_still_desired)
+ {
+ log::trace!("stopping background refresh for `{currency}`");
+ inner.currencies.remove(¤cy);
+ break;
+ }
+
+ drop(inner);
+
+ let interval = SERVE_TTL
+ .saturating_sub(2 * SOURCE_TIMEOUT_SECS)
+ .max(Duration::from_secs(1));
+
+ tokio::time::sleep(interval).await;
+ }
+ });
}
}
fn get_median_rate(source_results: Vec<SourceResult>) -> f64 {
- let mid = source_results.len() / 2;
- if source_results.len() % 2 == 1 {
- source_results[mid].price
+ let mut prices: Vec<f64> = source_results.iter().map(|r| r.price).collect();
+ prices.sort_by(|a, b| a.partial_cmp(b).unwrap());
+ let mid = prices.len() / 2;
+ if prices.len() % 2 == 1 {
+ prices[mid]
} else {
- f64::midpoint(source_results[mid - 1].price, source_results[mid].price)
+ f64::midpoint(prices[mid - 1], prices[mid])
}
}
diff --git a/tests/test_currencyrate.py b/tests/test_currencyrate.py
index df4f7288..66f704cb 100644
--- a/tests/test_currencyrate.py
+++ b/tests/test_currencyrate.py
@@ -44,12 +44,11 @@ def test_apis_batch1(node_factory):
convert = l1.rpc.call("currencyconvert", [100, "USD"])
LOGGER.info(convert)
- l1.daemon.wait_for_log("Using cached rates for USD")
-
assert "msat" in convert
assert convert["msat"] > 0
- assert convert["msat"] == pytest.approx(rates[int(len(rates) / 2)] * 100, abs=100)
+ assert convert["msat"] >= (rates[0] - 1) * 100
+ assert convert["msat"] <= (rates[len(rates) - 1] + 1) * 100
def test_apis_batch2(node_factory):
@@ -79,17 +78,20 @@ def test_apis_batch2(node_factory):
assert rates["bitstamp"] > 0
assert rates["coinbase"] > 0
- median_rate = int((rates["bitstamp"] + rates["coinbase"]) / 2)
+ rates = [
+ rates["bitstamp"],
+ rates["coinbase"],
+ ]
+ rates.sort()
convert = l1.rpc.call("currencyconvert", [100, "USD"])
LOGGER.info(convert)
- l1.daemon.wait_for_log("Using cached rates for USD")
-
assert "msat" in convert
assert convert["msat"] > 0
- assert convert["msat"] == pytest.approx(median_rate * 100, abs=100)
+ assert convert["msat"] >= (rates[0] - 1) * 100
+ assert convert["msat"] <= (rates[len(rates) - 1] + 1) * 100
def test_custom_source(node_factory):
@@ -127,17 +129,20 @@ def test_custom_source(node_factory):
assert rates["my-coingecko"] > 0
assert rates["my-kraken"] > 0
- median_rate = int((rates["my-coingecko"] + rates["my-kraken"]) / 2)
+ rates = [
+ rates["my-coingecko"],
+ rates["my-kraken"],
+ ]
+ rates.sort()
convert = l1.rpc.call("currencyconvert", [100, "USD"])
LOGGER.info(convert)
- l1.daemon.wait_for_log("Using cached rates for USD")
-
assert "msat" in convert
assert convert["msat"] > 0
- assert convert["msat"] == pytest.approx(median_rate * 100, abs=100)
+ assert convert["msat"] >= (rates[0] - 1) * 100
+ assert convert["msat"] <= (rates[len(rates) - 1] + 1) * 100
def test_no_sources(node_factory):
@@ -155,7 +160,8 @@ def test_no_sources(node_factory):
l1 = node_factory.get_node(options=opts)
with pytest.raises(
- RpcError, match="No sources configured or all failed, check the logs."
+ RpcError,
+ match="Unknown command 'currencyrates'",
):
rates = l1.rpc.call("currencyrates", ["USD"])
LOGGER.info(rates)
@@ -168,22 +174,23 @@ def test_invalid_currency(node_factory):
needle = l1.daemon.logsearch_start
with pytest.raises(
- RpcError, match="No sources configured or all failed, check the logs."
+ RpcError,
+ match=r"no results for `XXX`, is the currency supported\? Check the logs!",
):
rates = l1.rpc.call("currencyrates", ["XXX"])
LOGGER.info(rates)
l1.daemon.logsearch_start = needle
- l1.daemon.wait_for_log("Error fetching from bitstamp")
+ l1.daemon.wait_for_log("failed to get `XXX` rate from bitstamp")
l1.daemon.logsearch_start = needle
- l1.daemon.wait_for_log("Error fetching from coinbase")
+ l1.daemon.wait_for_log("failed to get `XXX` rate from coinbase")
l1.daemon.logsearch_start = needle
- l1.daemon.wait_for_log("Error fetching from coingecko")
+ l1.daemon.wait_for_log("failed to get `XXX` rate from coingecko")
l1.daemon.logsearch_start = needle
- l1.daemon.wait_for_log("Error fetching from kraken")
+ l1.daemon.wait_for_log("failed to get `XXX` rate from kraken")
l1.daemon.logsearch_start = needle
- l1.daemon.wait_for_log("Error fetching from blockchain.info")
+ l1.daemon.wait_for_log("failed to get `XXX` rate from blockchain.info")
l1.daemon.logsearch_start = needle
- l1.daemon.wait_for_log("Error fetching from coindesk")
+ l1.daemon.wait_for_log("failed to get `XXX` rate from coindesk")
l1.daemon.logsearch_start = needle
- l1.daemon.wait_for_log("Error fetching from binance")
+ l1.daemon.wait_for_log("failed to get `XXX` rate from binance")
Why this scored 35/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.