prices: add neroswap provider, make it the default

New PRICES_PROVIDER=neroswap arm fetches every rate in one call from the
canonical oracle at prices.neroswap.com (Kraken majors; WOW from Nonlogs
plus volume-weighted CexSwap), so every Such Software product quotes the
same WOW number. GRIN, which the oracle doesn't publish, falls back to
the direct nonlogs leg converted via the oracle's BTC/USD. coingecko and
kraken arms unchanged and still selectable.
This commit is contained in:
jwinterm
2026-08-15 20:14:50 -04:00
parent c2c07db34a
commit f38cae9581
2 changed files with 60 additions and 3 deletions
+3 -3
View File
@@ -780,7 +780,7 @@ impl Config {
grin: env_bool("FEATURE_GRIN", true),
},
prices: env_bool("FEATURE_PRICES", true),
prices_provider: env_or("PRICES_PROVIDER", "coingecko").to_lowercase(),
prices_provider: env_or("PRICES_PROVIDER", "neroswap").to_lowercase(),
prices_interval_secs: env_parse("PRICES_FETCH_INTERVAL_SECS", 300u64)?,
// Per-feed control. Distinguish UNSET from PRESENT-BUT-EMPTY:
// `env::var` (not `env_opt`, which collapses empty into None) so
@@ -1074,10 +1074,10 @@ impl Config {
if self.features.prices {
if !matches!(
self.features.prices_provider.as_str(),
"coingecko" | "kraken"
"neroswap" | "coingecko" | "kraken"
) {
return Err(cfg_err(format!(
"PRICES_PROVIDER {:?} is not supported; supported: coingecko, kraken",
"PRICES_PROVIDER {:?} is not supported; supported: neroswap, coingecko, kraken",
self.features.prices_provider
)));
}
+57
View File
@@ -26,6 +26,10 @@ const COINGECKO_URL: &str = "https://api.coingecko.com/api/v3/simple/price";
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";
/// The canonical Such Software price oracle (hash-wallet-prices Worker):
/// Kraken majors; WOW from Nonlogs + volume-weighted CexSwap, averaged and
/// KV-cached each minute. One call returns every rate, USD only.
const NEROSWAP_URL: &str = "https://prices.neroswap.com/v1/prices";
/// Our majors → Kraken USD pair. WOW/GRIN come from nonlogs instead.
const KRAKEN_PAIRS: &[(&str, &str)] = &[
("btc", "XXBTZUSD"),
@@ -127,6 +131,7 @@ impl PriceClient {
return Ok(HashMap::new());
}
match self.provider.as_str() {
"neroswap" => self.fetch_neroswap().await,
"coingecko" => self.fetch_coingecko().await,
"kraken" => self.fetch_kraken_nonlogs().await,
other => Err(AppError::ConfigError(format!(
@@ -265,6 +270,58 @@ impl PriceClient {
}
Ok(out)
}
/// The prices.neroswap.com oracle: one call, uppercase-symbol rate map.
/// Assets the oracle doesn't publish (GRIN) fall back to the direct
/// nonlogs leg, converted via the oracle's own BTC/USD. USD only — the
/// oracle does not serve other quote currencies.
async fn fetch_neroswap(&self) -> Result<HashMap<String, f64>, AppError> {
let resp = self
.http
.get(NEROSWAP_URL)
.send()
.await
.map_err(|_| AppError::NodeError("price fetch failed".into()))?;
if !resp.status().is_success() {
return Err(AppError::NodeError(format!(
"price provider returned HTTP {}",
resp.status().as_u16()
)));
}
let body = read_capped(resp, MAX_PRICE_BODY_BYTES).await?;
#[derive(Deserialize)]
struct NeroswapResp {
rates: HashMap<String, f64>,
}
let data: NeroswapResp = serde_json::from_slice(&body)
.map_err(|_| AppError::NodeError("invalid price response".into()))?;
let mut out = HashMap::new();
let mut missing = Vec::new();
for (asset, _) in &self.feeds {
match data.rates.get(&asset.to_uppercase()) {
Some(&price) if price.is_finite() && price > 0.0 => {
out.insert(asset.clone(), price);
}
_ => missing.push(asset.clone()),
}
}
if !missing.is_empty() {
let btc_usd = data
.rates
.get("BTC")
.copied()
.filter(|p| p.is_finite() && *p > 0.0);
if let Some(nonlogs) = self.fetch_nonlogs(btc_usd).await {
for asset in missing {
if let Some(&price) = nonlogs.get(&asset) {
out.insert(asset, price);
}
}
}
}
Ok(out)
}
}
/// A nonlogs.io market row (only the last trade price is used; other fields