mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(prices): Kraken + nonlogs provider with User-Agent (v0.2.x parity)
CoinGecko (the only v3 provider) 403s requests with no User-Agent — reqwest sends none — so the feed never populated. Port the working v0.2.x approach: - Add a browser User-Agent to the price HTTP client. - Add a "kraken" provider: Kraken for the majors (BTC/LTC/XMR, USD, one multi-pair call) + nonlogs.io for WOW/GRIN (Kraken doesn't list them; priced via each coin's -BTC ×BTC/USD and -USDT quotes, averaged). - Config validation accepts PRICES_PROVIDER=kraken. Deployed to api.smirk.cash with PRICES_PROVIDER=kraken.
This commit is contained in:
+2
-2
@@ -981,9 +981,9 @@ impl Config {
|
|||||||
// booting a feed that can never populate (which would still advertise
|
// booting a feed that can never populate (which would still advertise
|
||||||
// prices:true and silently serve nothing).
|
// prices:true and silently serve nothing).
|
||||||
if self.features.prices {
|
if self.features.prices {
|
||||||
if !matches!(self.features.prices_provider.as_str(), "coingecko") {
|
if !matches!(self.features.prices_provider.as_str(), "coingecko" | "kraken") {
|
||||||
return Err(cfg_err(format!(
|
return Err(cfg_err(format!(
|
||||||
"PRICES_PROVIDER {:?} is not supported; supported: coingecko",
|
"PRICES_PROVIDER {:?} is not supported; supported: coingecko, kraken",
|
||||||
self.features.prices_provider
|
self.features.prices_provider
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,24 @@ use std::collections::HashMap;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
/// Per-request timeout for the upstream price API.
|
/// Per-request timeout for the upstream price API.
|
||||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
/// Browser-like User-Agent. Public price APIs (CoinGecko, Kraken) 403 requests
|
||||||
|
/// that carry no UA — reqwest sends none by default. Mirrors the v0.2.x feed.
|
||||||
|
const USER_AGENT: &str =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) smirk-backend";
|
||||||
/// CoinGecko simple-price endpoint (fixed, trusted host).
|
/// CoinGecko simple-price endpoint (fixed, trusted host).
|
||||||
const COINGECKO_URL: &str = "https://api.coingecko.com/api/v3/simple/price";
|
const COINGECKO_URL: &str = "https://api.coingecko.com/api/v3/simple/price";
|
||||||
|
/// Kraken public ticker — the majors (BTC/LTC/XMR) in USD, one multi-pair call.
|
||||||
|
const KRAKEN_URL: &str = "https://api.kraken.com/0/public/Ticker";
|
||||||
|
/// Nonlogs markets — WOW/GRIN, which Kraken doesn't list (priced via BTC/USDT).
|
||||||
|
const NONLOGS_URL: &str = "https://api.nonlogs.io/api/markets";
|
||||||
|
/// Our majors → Kraken USD pair. WOW/GRIN come from nonlogs instead.
|
||||||
|
const KRAKEN_PAIRS: &[(&str, &str)] = &[("btc", "XXBTZUSD"), ("ltc", "XLTCZUSD"), ("xmr", "XXMRZUSD")];
|
||||||
/// Lower bound on the refresh interval, to stay within free-tier rate limits.
|
/// Lower bound on the refresh interval, to stay within free-tier rate limits.
|
||||||
const MIN_INTERVAL_SECS: u64 = 60;
|
const MIN_INTERVAL_SECS: u64 = 60;
|
||||||
/// Upper bound on the price response body. The real payload is a handful of
|
/// Upper bound on the price response body. The real payload is a handful of
|
||||||
@@ -71,6 +82,7 @@ impl PriceClient {
|
|||||||
pub fn new(provider: &str, currency: &str, assets: &[String]) -> Result<Self, AppError> {
|
pub fn new(provider: &str, currency: &str, assets: &[String]) -> Result<Self, AppError> {
|
||||||
let http = reqwest::Client::builder()
|
let http = reqwest::Client::builder()
|
||||||
.timeout(REQUEST_TIMEOUT)
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.user_agent(USER_AGENT)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|_| AppError::ConfigError("failed to build price HTTP client".into()))?;
|
.map_err(|_| AppError::ConfigError("failed to build price HTTP client".into()))?;
|
||||||
let feeds = assets
|
let feeds = assets
|
||||||
@@ -112,12 +124,98 @@ impl PriceClient {
|
|||||||
}
|
}
|
||||||
match self.provider.as_str() {
|
match self.provider.as_str() {
|
||||||
"coingecko" => self.fetch_coingecko().await,
|
"coingecko" => self.fetch_coingecko().await,
|
||||||
|
"kraken" => self.fetch_kraken_nonlogs().await,
|
||||||
other => Err(AppError::ConfigError(format!(
|
other => Err(AppError::ConfigError(format!(
|
||||||
"unsupported prices provider: {other}"
|
"unsupported prices provider: {other}"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The v0.2.x feed: Kraken for the majors (BTC/LTC/XMR, USD) + nonlogs.io for
|
||||||
|
/// WOW/GRIN (which Kraken doesn't list), priced via BTC. A per-source failure
|
||||||
|
/// drops that asset rather than blanking the whole feed. USD only — the Kraken
|
||||||
|
/// pairs are USD (config currency is `usd`).
|
||||||
|
async fn fetch_kraken_nonlogs(&self) -> Result<HashMap<String, f64>, AppError> {
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
|
||||||
|
// Majors from Kraken, in one multi-pair call.
|
||||||
|
let pairs: Vec<(&str, &str)> = self
|
||||||
|
.feeds
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(sym, _)| KRAKEN_PAIRS.iter().find(|(s, _)| s == sym).copied())
|
||||||
|
.collect();
|
||||||
|
if !pairs.is_empty() {
|
||||||
|
let joined = pairs.iter().map(|(_, p)| *p).collect::<Vec<_>>().join(",");
|
||||||
|
if let Ok(resp) = self.http.get(KRAKEN_URL).query(&[("pair", joined.as_str())]).send().await
|
||||||
|
{
|
||||||
|
if resp.status().is_success() {
|
||||||
|
let body = read_capped(resp, MAX_PRICE_BODY_BYTES).await?;
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct KrakenResp {
|
||||||
|
#[serde(default)]
|
||||||
|
error: Vec<String>,
|
||||||
|
result: Option<HashMap<String, KrakenTicker>>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct KrakenTicker {
|
||||||
|
c: Vec<String>, // [last-trade price, lot volume]
|
||||||
|
}
|
||||||
|
if let Ok(data) = serde_json::from_slice::<KrakenResp>(&body) {
|
||||||
|
if let (true, Some(result)) = (data.error.is_empty(), data.result) {
|
||||||
|
for (sym, pair) in &pairs {
|
||||||
|
if let Some(price) = result
|
||||||
|
.get(*pair)
|
||||||
|
.and_then(|t| t.c.first())
|
||||||
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
|
.filter(|p| p.is_finite() && *p > 0.0)
|
||||||
|
{
|
||||||
|
out.insert(sym.to_string(), price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WOW/GRIN from nonlogs, converted through BTC/USD.
|
||||||
|
if self.feeds.iter().any(|(s, _)| s == "wow" || s == "grin") {
|
||||||
|
if let Some(np) = self.fetch_nonlogs(out.get("btc").copied()).await {
|
||||||
|
for (sym, price) in np {
|
||||||
|
if self.feeds.iter().any(|(s, _)| *s == sym) {
|
||||||
|
out.insert(sym, price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WOW/GRIN prices from nonlogs.io, each averaged over its `-BTC` (×BTC/USD)
|
||||||
|
/// and `-USDT` markets. Returns `None` on any transport/parse failure (the
|
||||||
|
/// caller keeps the last good snapshot). Needs BTC/USD to convert the BTC pair.
|
||||||
|
async fn fetch_nonlogs(&self, btc_usd: Option<f64>) -> Option<HashMap<String, f64>> {
|
||||||
|
let btc_usd = btc_usd?;
|
||||||
|
let resp = self.http.get(NONLOGS_URL).send().await.ok()?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let body = read_capped(resp, MAX_PRICE_BODY_BYTES).await.ok()?;
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NonlogsResp {
|
||||||
|
markets: HashMap<String, NonlogsMarket>,
|
||||||
|
}
|
||||||
|
let data: NonlogsResp = serde_json::from_slice(&body).ok()?;
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
for sym in ["wow", "grin"] {
|
||||||
|
if let Some(p) = nonlogs_usd_price(&data.markets, sym, btc_usd) {
|
||||||
|
out.insert(sym.to_string(), p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
async fn fetch_coingecko(&self) -> Result<HashMap<String, f64>, AppError> {
|
async fn fetch_coingecko(&self) -> Result<HashMap<String, f64>, AppError> {
|
||||||
let ids = self
|
let ids = self
|
||||||
.feeds
|
.feeds
|
||||||
@@ -160,6 +258,44 @@ impl PriceClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A nonlogs.io market row (only the last trade price is used; other fields
|
||||||
|
/// ignored).
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NonlogsMarket {
|
||||||
|
last_price: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// USD price for `sym` (lowercase) from nonlogs markets: average of its `-BTC`
|
||||||
|
/// quote (×`btc_usd`) and its `-USDT` quote, whichever are present. `None` if
|
||||||
|
/// neither is. Mirrors the v0.2.x conversion.
|
||||||
|
fn nonlogs_usd_price(
|
||||||
|
markets: &HashMap<String, NonlogsMarket>,
|
||||||
|
sym: &str,
|
||||||
|
btc_usd: f64,
|
||||||
|
) -> Option<f64> {
|
||||||
|
let up = sym.to_uppercase();
|
||||||
|
let parse = |pair: &str| -> Option<f64> {
|
||||||
|
markets
|
||||||
|
.get(pair)?
|
||||||
|
.last_price
|
||||||
|
.as_deref()?
|
||||||
|
.parse::<f64>()
|
||||||
|
.ok()
|
||||||
|
.filter(|p| *p > 0.0)
|
||||||
|
};
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
if let Some(btc) = parse(&format!("{up}-BTC")) {
|
||||||
|
sources.push(btc * btc_usd);
|
||||||
|
}
|
||||||
|
if let Some(usdt) = parse(&format!("{up}-USDT")) {
|
||||||
|
sources.push(usdt);
|
||||||
|
}
|
||||||
|
if sources.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(sources.iter().sum::<f64>() / sources.len() as f64)
|
||||||
|
}
|
||||||
|
|
||||||
/// Clamp the configured refresh interval to a provider-friendly minimum.
|
/// Clamp the configured refresh interval to a provider-friendly minimum.
|
||||||
pub fn refresh_interval(configured_secs: u64) -> Duration {
|
pub fn refresh_interval(configured_secs: u64) -> Duration {
|
||||||
Duration::from_secs(configured_secs.max(MIN_INTERVAL_SECS))
|
Duration::from_secs(configured_secs.max(MIN_INTERVAL_SECS))
|
||||||
|
|||||||
Reference in New Issue
Block a user