pub(crate) mod client;
pub(crate) mod crypto;
pub(crate) mod models;
use std::time::Duration;
use crate::adapters::singleton::keyless_limiter;
use crate::error::Result;
use crate::models::crypto::defi::{ChainTvl, StablecoinSupply};
use client::DefiLlamaClient;
const DEFILLAMA_RATE_PER_SEC: f64 = 5.0;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(45);
keyless_limiter!(rate = DEFILLAMA_RATE_PER_SEC);
fn client() -> Result<DefiLlamaClient> {
DefiLlamaClient::new(
DEFAULT_TIMEOUT,
shared_limiter(),
client::LLAMA_BASE,
client::STABLECOINS_BASE,
)
}
pub(crate) use crypto::{fetch_protocol_tvl_history_response, fetch_protocol_tvl_response};
fn peg_value(map: &std::collections::HashMap<String, f64>) -> Option<f64> {
map.values().copied().next()
}
pub(crate) async fn chains() -> Result<Vec<ChainTvl>> {
let mut chains: Vec<ChainTvl> = client()?
.chains()
.await?
.into_iter()
.map(|chain| ChainTvl {
name: chain.name,
token_symbol: chain.token_symbol,
gecko_id: chain.gecko_id,
chain_id: chain.chain_id,
tvl: chain.tvl,
})
.collect();
chains.sort_by(|a, b| {
b.tvl
.unwrap_or(f64::MIN)
.total_cmp(&a.tvl.unwrap_or(f64::MIN))
.then_with(|| a.name.cmp(&b.name))
});
Ok(chains)
}
pub(crate) async fn stablecoins() -> Result<Vec<StablecoinSupply>> {
let mut coins: Vec<StablecoinSupply> = client()?
.stablecoins()
.await?
.pegged_assets
.into_iter()
.map(|asset| StablecoinSupply {
name: asset.name,
symbol: asset.symbol,
gecko_id: asset.gecko_id,
peg_type: asset.peg_type,
peg_mechanism: asset.peg_mechanism,
circulating: peg_value(&asset.circulating),
circulating_prev_day: peg_value(&asset.circulating_prev_day),
circulating_prev_week: peg_value(&asset.circulating_prev_week),
circulating_prev_month: peg_value(&asset.circulating_prev_month),
})
.collect();
coins.sort_by(|a, b| {
b.circulating
.unwrap_or(f64::MIN)
.total_cmp(&a.circulating.unwrap_or(f64::MIN))
.then_with(|| a.name.cmp(&b.name))
});
Ok(coins)
}
#[cfg(test)]
mod tests {
use super::crypto::{chain_allocations, change_percent, slug, to_history, to_protocol_tvl};
use super::models::ProtocolResponse;
use super::*;
use crate::error::FinanceError;
use crate::rate_limiter::RateLimiter;
use std::sync::Arc;
fn test_client(base_url: &str) -> DefiLlamaClient {
DefiLlamaClient::new(
Duration::from_secs(5),
Arc::new(RateLimiter::new(100.0)),
base_url,
base_url,
)
.unwrap()
}
fn protocol_payload() -> String {
serde_json::json!({
"id": "111",
"name": "AAVE V3",
"symbol": "AAVE",
"url": "https://aave.com",
"chains": ["Ethereum", "Arbitrum"],
"currentChainTvls": {
"Ethereum": 9_000_000_000.0_f64,
"Arbitrum": 1_000_000_000.0_f64,
"Ethereum-borrowed": 5_000_000_000.0_f64,
"pool2": 12_345.0_f64
},
"tvl": [
{ "date": 1785110400_i64, "totalLiquidityUSD": 8_000_000_000.0_f64 },
{ "date": 1785628800_i64, "totalLiquidityUSD": 9_500_000_000.0_f64 },
{ "date": 1785715200_i64, "totalLiquidityUSD": 10_000_000_000.0_f64 }
],
"mcap": 3_500_000_000.0_f64
})
.to_string()
}
#[test]
fn ids_normalise_to_defillama_slugs() {
assert_eq!(slug("aave"), "aave");
assert_eq!(slug("AAVE"), "aave");
assert_eq!(slug("Curve DEX"), "curve-dex");
assert_eq!(slug(" lido_finance "), "lido-finance");
}
#[test]
fn breakdown_keys_are_excluded_from_chain_allocations() {
let response: ProtocolResponse = serde_json::from_str(&protocol_payload()).unwrap();
let allocations = chain_allocations(&response);
assert_eq!(allocations.len(), 2);
assert_eq!(allocations[0].chain, "Ethereum");
assert_eq!(allocations[0].tvl, 9_000_000_000.0);
assert_eq!(allocations[1].chain, "Arbitrum");
assert!(
!allocations.iter().any(|a| a.chain.contains("borrowed")),
"a borrowed-capital breakdown key leaked into the allocations"
);
}
#[test]
fn an_empty_chains_list_falls_back_to_the_non_breakdown_keys() {
let payload = serde_json::json!({
"name": "Aave",
"chains": [],
"currentChainTvls": {
"Ethereum": 9_000_000_000.0_f64,
"Ethereum-borrowed": 5_000_000_000.0_f64,
"Ethereum-staking": 1_000_000.0_f64,
"OP Mainnet": 2_000_000_000.0_f64,
"borrowed": 7_000_000_000.0_f64,
"pool2": 12_345.0_f64,
"staking": 999.0_f64
},
"tvl": []
})
.to_string();
let response: ProtocolResponse = serde_json::from_str(&payload).unwrap();
let summary = to_protocol_tvl("aave", &response);
assert_eq!(summary.chains, vec!["Ethereum", "OP Mainnet"]);
assert_eq!(summary.tvl_by_chain.len(), 2);
assert!(
summary
.tvl_by_chain
.iter()
.all(|a| summary.chains.contains(&a.chain)),
"an allocation named something absent from the chain list"
);
}
#[test]
fn change_is_measured_against_the_closest_earlier_snapshot() {
let history = vec![
super::super::super::models::crypto::defi::TvlPoint {
timestamp: 1785110400,
tvl: 8_000_000_000.0,
},
super::super::super::models::crypto::defi::TvlPoint {
timestamp: 1785628800,
tvl: 9_500_000_000.0,
},
super::super::super::models::crypto::defi::TvlPoint {
timestamp: 1785715200,
tvl: 10_000_000_000.0,
},
];
let one_day = change_percent(&history, 1).unwrap();
assert!(
(one_day - (10.0 - 9.5) / 9.5 * 100.0).abs() < 1e-9,
"{one_day}"
);
let week = change_percent(&history, 7).unwrap();
assert!((week - (10.0 - 8.0) / 8.0 * 100.0).abs() < 1e-9, "{week}");
assert_eq!(change_percent(&history, 3650), None);
assert_eq!(change_percent(&[], 1), None);
}
#[tokio::test]
async fn protocol_maps_to_a_tvl_summary_and_history() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/protocol/aave")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(protocol_payload())
.create_async()
.await;
let response = test_client(&server.url()).protocol("aave").await.unwrap();
let summary = to_protocol_tvl("aave", &response);
assert_eq!(summary.slug, "aave");
assert_eq!(summary.name.as_deref(), Some("AAVE V3"));
assert_eq!(summary.symbol.as_deref(), Some("AAVE"));
assert_eq!(summary.chains, vec!["Ethereum", "Arbitrum"]);
assert_eq!(summary.tvl, Some(10_000_000_000.0));
assert_eq!(summary.market_cap, Some(3_500_000_000.0));
assert_eq!(summary.tvl_by_chain.len(), 2);
let history = to_history(&response);
assert_eq!(history.len(), 3);
assert!(history.windows(2).all(|w| w[0].timestamp < w[1].timestamp));
}
#[tokio::test]
async fn unknown_protocol_maps_to_symbol_not_found() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/protocol/not-a-protocol")
.with_status(400)
.with_body("Protocol not found")
.create_async()
.await;
let err = test_client(&server.url())
.protocol("not-a-protocol")
.await
.unwrap_err();
assert!(
matches!(err, FinanceError::SymbolNotFound { .. }),
"{err:?}"
);
}
#[tokio::test]
async fn chains_come_back_ranked_by_tvl() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/v2/chains")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!([
{ "name": "Harmony", "tokenSymbol": "ONE", "gecko_id": "harmony",
"chainId": 1666600000_i64, "tvl": 204577.23_f64 },
{ "name": "Ethereum", "tokenSymbol": "ETH", "gecko_id": "ethereum",
"chainId": 1_i64, "tvl": 60_000_000_000.0_f64 },
{ "name": "Corn", "tokenSymbol": "CORN", "gecko_id": "corn-3",
"chainId": 21000000_i64, "tvl": 0.0_f64 }
])
.to_string(),
)
.create_async()
.await;
let raw = test_client(&server.url()).chains().await.unwrap();
let mut ranked: Vec<_> = raw
.into_iter()
.map(|c| (c.name, c.tvl.unwrap_or(0.0)))
.collect();
ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
assert_eq!(ranked[0].0, "Ethereum");
}
#[tokio::test]
async fn stablecoin_supplies_read_the_peg_keyed_maps() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/stablecoins")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"peggedAssets": [{
"id": "1", "name": "Tether", "symbol": "USDT",
"gecko_id": "tether", "pegType": "peggedUSD",
"pegMechanism": "fiat-backed",
"circulating": { "peggedUSD": 183_195_719_854.93_f64 },
"circulatingPrevDay": { "peggedUSD": 183_301_799_410.99_f64 },
"circulatingPrevWeek": { "peggedUSD": 184_242_294_121.55_f64 },
"circulatingPrevMonth": { "peggedUSD": 184_050_168_657.80_f64 }
}]
})
.to_string(),
)
.create_async()
.await;
let response = test_client(&server.url()).stablecoins().await.unwrap();
let asset = &response.pegged_assets[0];
assert_eq!(asset.symbol.as_deref(), Some("USDT"));
assert_eq!(peg_value(&asset.circulating), Some(183_195_719_854.93));
assert_eq!(
peg_value(&asset.circulating_prev_day),
Some(183_301_799_410.99)
);
}
}