use crate::config::settings::Settings;
use crate::lnurl::HTTP_CLIENT;
use crate::nip33::new_exchange_rates_event;
use crate::util::{get_keys, get_nostr_client};
use chrono::Utc;
use mostro_core::prelude::*;
use nostr_sdk::prelude::*;
use once_cell::sync::Lazy;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::RwLock;
use tracing::{error, info, warn};
#[derive(Debug, Deserialize)]
struct YadioResponse {
#[serde(rename = "BTC")]
btc: HashMap<String, Option<f64>>,
}
static BITCOIN_PRICES: Lazy<RwLock<HashMap<String, f64>>> =
Lazy::new(|| RwLock::new(HashMap::new()));
pub struct BitcoinPriceManager;
impl BitcoinPriceManager {
pub async fn update_prices() -> Result<(), MostroError> {
let mostro_settings = Settings::get_mostro();
let api_url = format!("{}/exrates/BTC", mostro_settings.bitcoin_price_api_url);
let response = HTTP_CLIENT
.get(&api_url)
.send()
.await
.map_err(|_| MostroInternalErr(ServiceError::NoAPIResponse))?;
let yadio_response: YadioResponse = response
.json()
.await
.map_err(|_| MostroInternalErr(ServiceError::MessageSerializationError))?;
let rates_clone: HashMap<String, f64> = yadio_response
.btc
.into_iter()
.filter_map(|(code, value)| match value {
Some(v) if v.is_finite() && v > 0.0 => Some((code, v)),
_ => None,
})
.collect();
if rates_clone.is_empty() {
warn!("Yadio returned no usable BTC rates; keeping previously cached prices");
return Ok(());
}
info!(
"Bitcoin prices updated. Got BTC price in {} fiat currencies",
rates_clone.len()
);
{
let mut prices_write = BITCOIN_PRICES
.write()
.map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?;
*prices_write = rates_clone.clone();
}
if mostro_settings.publish_exchange_rates_to_nostr {
if let Err(e) = Self::publish_rates_to_nostr(&rates_clone).await {
error!("Failed to publish exchange rates to Nostr: {}", e);
}
}
Ok(())
}
async fn publish_rates_to_nostr(rates: &HashMap<String, f64>) -> Result<(), MostroError> {
let keys = get_keys().map_err(|e| {
error!("Failed to get Mostro keys: {}", e);
MostroInternalErr(ServiceError::IOError(e.to_string()))
})?;
let mut wrapper = HashMap::new();
wrapper.insert("BTC".to_string(), rates.clone());
let formatted_rates = wrapper;
let content = serde_json::to_string(&formatted_rates)
.map_err(|_| MostroInternalErr(ServiceError::MessageSerializationError))?;
let timestamp = Utc::now().timestamp();
let mostro_settings = Settings::get_mostro();
let update_interval = mostro_settings.exchange_rates_update_interval_seconds;
let expiration_seconds = std::cmp::min(update_interval * 2, 3600);
let expiration = timestamp + expiration_seconds as i64;
let tags = Tags::from_list(vec![
Tag::custom(
TagKind::Custom("published_at".into()),
vec![timestamp.to_string()],
),
Tag::custom(TagKind::Custom("source".into()), vec!["yadio".to_string()]),
Tag::expiration(Timestamp::from(expiration as u64)),
]);
let event = new_exchange_rates_event(&keys, &content, tags).map_err(|e| {
error!("Failed to create exchange rates event: {}", e);
MostroInternalErr(ServiceError::MessageSerializationError)
})?;
let client = get_nostr_client().map_err(|e| {
error!("Failed to get Nostr client: {}", e);
e
})?;
let timeout_duration = std::time::Duration::from_secs(30);
match tokio::time::timeout(timeout_duration, client.send_event(&event)).await {
Ok(Ok(output)) => {
info!(
"Exchange rates published to Nostr ({} currencies). Output: {:?}",
rates.len(),
output
);
}
Ok(Err(e)) => {
error!("Failed to send exchange rates event to relays: {}", e);
}
Err(_) => {
error!("Timeout publishing exchange rates to Nostr (30s exceeded)");
}
}
Ok(())
}
pub fn get_price(currency: &str) -> Result<f64, MostroError> {
let prices_read: std::sync::RwLockReadGuard<'_, HashMap<String, f64>> = BITCOIN_PRICES
.read()
.map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?;
prices_read
.get(currency)
.cloned()
.ok_or(MostroInternalErr(ServiceError::NoAPIResponse))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_rates_structure() {
let mut input_rates = HashMap::new();
input_rates.insert("USD".to_string(), 50000.0);
input_rates.insert("EUR".to_string(), 45000.0);
let mut wrapper = HashMap::new();
wrapper.insert("BTC".to_string(), input_rates.clone());
assert_eq!(wrapper.len(), 1);
assert!(wrapper.contains_key("BTC"));
assert_eq!(wrapper.get("BTC").unwrap().get("USD"), Some(&50000.0));
assert_eq!(wrapper.get("BTC").unwrap().get("EUR"), Some(&45000.0));
}
#[test]
fn test_rates_json_serialization() {
let mut input_rates = HashMap::new();
input_rates.insert("USD".to_string(), 50000.0);
input_rates.insert("EUR".to_string(), 45000.0);
let mut wrapper = HashMap::new();
wrapper.insert("BTC".to_string(), input_rates);
let json = serde_json::to_string(&wrapper).unwrap();
assert!(json.contains("\"BTC\""));
assert!(json.contains("\"USD\""));
assert!(json.contains("50000"));
assert!(json.contains("\"EUR\""));
assert!(json.contains("45000"));
assert!(!json.contains("\"BTC\":1"));
}
#[test]
fn test_yadio_response_deserialization() {
let json_response = r#"
{
"BTC": {
"USD": 50000.0,
"EUR": 45000.0,
"GBP": 40000.0
}
}
"#;
let result: Result<YadioResponse, _> = serde_json::from_str(json_response);
assert!(result.is_ok());
let response = result.unwrap();
assert_eq!(response.btc.get("USD"), Some(&Some(50000.0)));
assert_eq!(response.btc.get("EUR"), Some(&Some(45000.0)));
assert_eq!(response.btc.get("GBP"), Some(&Some(40000.0)));
assert_eq!(response.btc.len(), 3);
}
#[test]
fn test_yadio_response_with_null_rate_is_parsed_and_filtered() {
let json_response = r#"
{
"BTC": { "USD": 75899.55, "EUR": 65393.99, "BGN": null },
"base": "BTC",
"timestamp": 1779480604069
}
"#;
let response: YadioResponse = serde_json::from_str(json_response).expect("must parse");
assert_eq!(response.btc.get("BGN"), Some(&None));
let rates: HashMap<String, f64> = response
.btc
.into_iter()
.filter_map(|(code, value)| match value {
Some(v) if v.is_finite() && v > 0.0 => Some((code, v)),
_ => None,
})
.collect();
assert_eq!(rates.len(), 2, "null BGN must be dropped");
assert_eq!(rates.get("USD"), Some(&75899.55));
assert_eq!(rates.get("EUR"), Some(&65393.99));
assert!(!rates.contains_key("BGN"));
}
#[test]
fn test_yadio_response_all_null_filters_to_empty() {
let json_response = r#"{ "BTC": { "BGN": null, "ZZZ": null }, "base": "BTC" }"#;
let response: YadioResponse = serde_json::from_str(json_response).expect("must parse");
let rates: HashMap<String, f64> = response
.btc
.into_iter()
.filter_map(|(code, value)| match value {
Some(v) if v.is_finite() && v > 0.0 => Some((code, v)),
_ => None,
})
.collect();
assert!(
rates.is_empty(),
"all-null response must filter to an empty rate set"
);
}
#[test]
fn test_yadio_response_invalid_json() {
let invalid_json = r#"{"invalid": "structure"}"#;
let result: Result<YadioResponse, _> = serde_json::from_str(invalid_json);
assert!(result.is_err());
}
#[test]
fn test_yadio_response_empty_btc() {
let json_response = r#"{"BTC": {}}"#;
let result: Result<YadioResponse, _> = serde_json::from_str(json_response);
assert!(result.is_ok());
let response = result.unwrap();
assert_eq!(response.btc.len(), 0);
}
#[test]
fn test_currency_code_validation() {
let valid_currencies = vec!["USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF"];
let invalid_currencies = vec!["", "us", "USDD", "123", "usd"];
for currency in valid_currencies {
let _result = BitcoinPriceManager::get_price(currency);
}
for currency in invalid_currencies {
let _result = BitcoinPriceManager::get_price(currency);
}
}
#[test]
fn test_bitcoin_price_manager_api_url() {
let expected_base = "https://api.yadio.io";
assert!(expected_base.starts_with("https://"));
assert!(expected_base.contains("yadio.io"));
}
mod error_handling_tests {
use super::*;
#[test]
fn test_json_parsing_errors() {
let invalid_responses = vec![
"", "{", "null", "[]", r#"{"BTC": null}"#, r#"{"BTC": []}"#, r#"{"BTC": {"USD": "invalid"}}"#, ];
for invalid_json in invalid_responses {
let result: Result<YadioResponse, _> = serde_json::from_str(invalid_json);
assert!(result.is_err());
}
}
}
mod price_cache_tests {
use super::*;
#[test]
fn test_price_cache_operations() {
let test_currencies = HashMap::from([
("USD".to_string(), 50000.0),
("EUR".to_string(), 45000.0),
("GBP".to_string(), 40000.0),
]);
assert_eq!(test_currencies.len(), 3);
assert!(test_currencies.contains_key("USD"));
assert_eq!(test_currencies.get("USD"), Some(&50000.0));
for currency in test_currencies.keys() {
assert_eq!(currency, ¤cy.to_uppercase());
assert!(currency.len() == 3); }
}
#[test]
fn test_concurrent_access_safety() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
let success = Arc::new(AtomicBool::new(true));
let mut handles = vec![];
for _ in 0..5 {
let success_clone = Arc::clone(&success);
let handle = thread::spawn(move || {
for _ in 0..10 {
match BitcoinPriceManager::get_price("USD") {
Ok(_) | Err(_) => {
}
}
}
success_clone.store(true, Ordering::Relaxed);
});
handles.push(handle);
}
for handle in handles {
handle.join().expect("Thread should not panic");
}
assert!(success.load(Ordering::Relaxed));
}
}
}