use serde_json::Value;
use std::time::{SystemTime, UNIX_EPOCH};
use tail_fin_common::TailFinError;
use wreq::header::{HeaderMap, HeaderValue, ACCEPT, ORIGIN, REFERER};
use crate::parsing::{parse_address_enriched, parse_search_results, parse_transfers};
use crate::signing::sign_payload;
use crate::types::{AddressEnriched, SearchResults, TransfersPage, TransfersQuery};
const API_BASE: &str = "https://api.arkm.com";
const ORIGIN_HEADER: &str = "https://intel.arkm.com";
const REFERER_HEADER: &str = "https://intel.arkm.com/";
const ADDRESS_ENRICHED_INCLUDES: &[(&str, &str)] = &[
("includeTags", "true"),
("includeEntityPredictions", "true"),
("includeClusters", "true"),
];
pub struct ArkhamClient {
client: wreq::Client,
}
#[allow(clippy::too_many_arguments)]
impl ArkhamClient {
pub fn new() -> Result<Self, TailFinError> {
let emu = wreq_util::EmulationOption::builder()
.emulation(wreq_util::Emulation::Chrome145)
.emulation_os(wreq_util::EmulationOS::MacOS)
.build();
let client = wreq::Client::builder()
.emulation(emu)
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| TailFinError::Api(format!("failed to build HTTP client: {e}")))?;
Ok(Self { client })
}
pub async fn signed_get(
&self,
path: &str,
query_pairs: &[(&str, &str)],
) -> Result<Value, TailFinError> {
let url = if query_pairs.is_empty() {
format!("{API_BASE}{path}")
} else {
let qs = query_pairs
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
format!("{API_BASE}{path}?{qs}")
};
self.send_signed(&url, path).await
}
async fn send_signed(&self, url: &str, path: &str) -> Result<Value, TailFinError> {
let ts = current_unix_seconds()?;
let payload = sign_payload(path, &ts);
let headers = build_signed_headers(&ts, &payload)?;
let resp = self
.client
.get(url)
.headers(headers)
.send()
.await
.map_err(|e| TailFinError::Api(format!("Arkham GET {path} failed: {e}")))?;
let status = resp.status();
let body_bytes = resp
.bytes()
.await
.map_err(|e| TailFinError::Api(format!("Arkham GET {path} body read failed: {e}")))?;
if !status.is_success() {
let preview = String::from_utf8_lossy(&body_bytes);
return Err(TailFinError::Api(format!(
"Arkham GET {path} HTTP {}: {}",
status.as_u16(),
preview.chars().take(300).collect::<String>()
)));
}
serde_json::from_slice::<Value>(&body_bytes)
.map_err(|e| TailFinError::Api(format!("Arkham GET {path} returned non-JSON: {e}")))
}
pub async fn search(&self, query: &str) -> Result<SearchResults, TailFinError> {
let body = self
.signed_get("/intelligence/search", &[("query", query)])
.await?;
parse_search_results(&body)
}
pub async fn address_enriched(&self, address: &str) -> Result<AddressEnriched, TailFinError> {
let path = format!("/intelligence/address_enriched/{address}/all");
let body = self.signed_get(&path, ADDRESS_ENRICHED_INCLUDES).await?;
parse_address_enriched(&body)
}
pub async fn transfers(&self, q: &TransfersQuery<'_>) -> Result<TransfersPage, TailFinError> {
let pairs = transfers_query_pairs(q);
let pair_refs: Vec<(&str, &str)> = pairs.iter().map(|(k, v)| (*k, v.as_str())).collect();
let body = self.signed_get("/transfers", &pair_refs).await?;
parse_transfers(&body)
}
pub async fn balances_address(
&self,
address: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/balances/address/{address}");
self.signed_get(&path, &chains_pairs(chains)).await
}
pub async fn counterparties_address(
&self,
address: &str,
chains: Option<&str>,
flow: Option<&str>,
time_last: Option<&str>,
usd_gte: Option<&str>,
limit: Option<u32>,
) -> Result<Value, TailFinError> {
let path = format!("/counterparties/address/{address}");
let mut q = QueryBuf::new();
q.push_opt("chains", chains);
q.push_opt("flow", flow);
q.push_opt("timeLast", time_last);
q.push_opt("usdGte", usd_gte);
q.push_opt_num("limit", limit);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn flow_address(
&self,
address: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/flow/address/{address}");
self.signed_get(&path, &chains_pairs(chains)).await
}
pub async fn history_address(
&self,
address: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/history/address/{address}");
self.signed_get(&path, &chains_pairs(chains)).await
}
pub async fn intelligence_address(
&self,
address: &str,
chain: &str,
) -> Result<Value, TailFinError> {
let path = format!("/intelligence/address/{address}");
self.signed_get(&path, &[("chain", chain)]).await
}
pub async fn intelligence_address_all(&self, address: &str) -> Result<Value, TailFinError> {
let path = format!("/intelligence/address/{address}/all");
self.signed_get(&path, &[]).await
}
pub async fn intelligence_address_enriched_chain(
&self,
address: &str,
chain: &str,
) -> Result<Value, TailFinError> {
let path = format!("/intelligence/address_enriched/{address}");
let mut pairs = vec![("chain", chain)];
pairs.extend_from_slice(ADDRESS_ENRICHED_INCLUDES);
self.signed_get(&path, &pairs).await
}
pub async fn intelligence_contract(
&self,
chain: &str,
address: &str,
) -> Result<Value, TailFinError> {
let path = format!("/intelligence/contract/{chain}/{address}");
self.signed_get(&path, &[]).await
}
pub async fn loans_address(
&self,
address: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/loans/address/{address}");
self.signed_get(&path, &chains_pairs(chains)).await
}
pub async fn portfolio_address(
&self,
address: &str,
time: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/portfolio/address/{address}");
let mut q = QueryBuf::new();
q.push("time", time);
q.push_opt("chains", chains);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn portfolio_timeseries_address(
&self,
address: &str,
pricing_id: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/portfolio/timeSeries/address/{address}");
let mut q = QueryBuf::new();
q.push("pricingId", pricing_id);
q.push_opt("chains", chains);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn volume_address(
&self,
address: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/volume/address/{address}");
self.signed_get(&path, &chains_pairs(chains)).await
}
pub async fn balances_entity(
&self,
entity: &str,
chains: Option<&str>,
cheap: Option<bool>,
) -> Result<Value, TailFinError> {
let path = format!("/balances/entity/{entity}");
let mut q = QueryBuf::new();
q.push_opt("chains", chains);
q.push_opt_bool("cheap", cheap);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn counterparties_entity(
&self,
entity: &str,
chains: Option<&str>,
flow: Option<&str>,
time_last: Option<&str>,
usd_gte: Option<&str>,
limit: Option<u32>,
) -> Result<Value, TailFinError> {
let path = format!("/counterparties/entity/{entity}");
let mut q = QueryBuf::new();
q.push_opt("chains", chains);
q.push_opt("flow", flow);
q.push_opt("timeLast", time_last);
q.push_opt("usdGte", usd_gte);
q.push_opt_num("limit", limit);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn flow_entity(
&self,
entity: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/flow/entity/{entity}");
self.signed_get(&path, &chains_pairs(chains)).await
}
pub async fn history_entity(
&self,
entity: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/history/entity/{entity}");
self.signed_get(&path, &chains_pairs(chains)).await
}
pub async fn loans_entity(
&self,
entity: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/loans/entity/{entity}");
self.signed_get(&path, &chains_pairs(chains)).await
}
pub async fn portfolio_entity(
&self,
entity: &str,
time: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/portfolio/entity/{entity}");
let mut q = QueryBuf::new();
q.push("time", time);
q.push_opt("chains", chains);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn portfolio_timeseries_entity(
&self,
entity: &str,
pricing_id: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/portfolio/timeSeries/entity/{entity}");
let mut q = QueryBuf::new();
q.push("pricingId", pricing_id);
q.push_opt("chains", chains);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn volume_entity(
&self,
entity: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/volume/entity/{entity}");
self.signed_get(&path, &chains_pairs(chains)).await
}
pub async fn intelligence_entity(&self, entity: &str) -> Result<Value, TailFinError> {
let path = format!("/intelligence/entity/{entity}");
self.signed_get(&path, &[]).await
}
pub async fn intelligence_entity_summary(&self, entity: &str) -> Result<Value, TailFinError> {
let path = format!("/intelligence/entity/{entity}/summary");
self.signed_get(&path, &[]).await
}
pub async fn intelligence_entity_predictions(
&self,
entity: &str,
) -> Result<Value, TailFinError> {
let path = format!("/intelligence/entity_predictions/{entity}");
self.signed_get(&path, &[]).await
}
pub async fn intelligence_entity_balance_changes(
&self,
order_by: &str,
order_dir: &str,
interval: &str,
limit: u32,
chains: Option<&str>,
entity_types: Option<&str>,
entity_ids: Option<&str>,
offset: Option<u32>,
) -> Result<Value, TailFinError> {
let mut q = QueryBuf::new();
q.push("orderBy", order_by);
q.push("orderDir", order_dir);
q.push("interval", interval);
q.push_opt_num("limit", Some(limit));
q.push_opt("chains", chains);
q.push_opt("entityTypes", entity_types);
q.push_opt("entityIds", entity_ids);
q.push_opt_num("offset", offset);
self.signed_get("/intelligence/entity_balance_changes", &q.as_pairs())
.await
}
pub async fn intelligence_entity_types(&self) -> Result<Value, TailFinError> {
self.signed_get("/intelligence/entity_types", &[]).await
}
pub async fn token_addresses(&self, id: &str) -> Result<Value, TailFinError> {
let path = format!("/token/addresses/{id}");
self.signed_get(&path, &[]).await
}
pub async fn token_balance_by_addr(
&self,
chain: &str,
token_address: &str,
entity_id: Option<&str>,
holder_address: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/token/balance/{chain}/{token_address}");
let mut q = QueryBuf::new();
q.push_opt("entityID", entity_id);
q.push_opt("address", holder_address);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn token_balance_by_id(
&self,
id: &str,
entity_id: Option<&str>,
holder_address: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/token/balance/{id}");
let mut q = QueryBuf::new();
q.push_opt("entityID", entity_id);
q.push_opt("address", holder_address);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn token_holders_by_addr(
&self,
chain: &str,
token_address: &str,
group_by_entity: Option<bool>,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<Value, TailFinError> {
let path = format!("/token/holders/{chain}/{token_address}");
let mut q = QueryBuf::new();
q.push_opt_bool("groupByEntity", group_by_entity);
q.push_opt_num("limit", limit);
q.push_opt_num("offset", offset);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn token_holders_by_id(
&self,
id: &str,
group_by_entity: Option<bool>,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<Value, TailFinError> {
let path = format!("/token/holders/{id}");
let mut q = QueryBuf::new();
q.push_opt_bool("groupByEntity", group_by_entity);
q.push_opt_num("limit", limit);
q.push_opt_num("offset", offset);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn token_market(&self, id: &str) -> Result<Value, TailFinError> {
let path = format!("/token/market/{id}");
self.signed_get(&path, &[]).await
}
pub async fn token_price_history_by_addr(
&self,
chain: &str,
token_address: &str,
daily: Option<bool>,
) -> Result<Value, TailFinError> {
let path = format!("/token/price/history/{chain}/{token_address}");
let mut q = QueryBuf::new();
q.push_opt_bool("daily", daily);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn token_price_history_by_id(
&self,
id: &str,
daily: Option<bool>,
) -> Result<Value, TailFinError> {
let path = format!("/token/price/history/{id}");
let mut q = QueryBuf::new();
q.push_opt_bool("daily", daily);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn token_price_change(
&self,
id: &str,
past_time: &str,
) -> Result<Value, TailFinError> {
let path = format!("/token/price_change/{id}");
let mut q = QueryBuf::new();
q.push("pastTime", past_time);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn token_top(
&self,
timeframe: &str,
order_by_agg: &str,
order_by_percent: bool,
order_by_desc: bool,
from: u32,
size: u32,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let mut q = QueryBuf::new();
q.push("timeframe", timeframe);
q.push("orderByAgg", order_by_agg);
q.push(
"orderByPercent",
if order_by_percent { "true" } else { "false" },
);
q.push("orderByDesc", if order_by_desc { "true" } else { "false" });
q.push_opt_num("from", Some(from));
q.push_opt_num("size", Some(size));
q.push_opt("chains", chains);
self.signed_get("/token/top", &q.as_pairs()).await
}
pub async fn token_top_flow_by_addr(
&self,
chain: &str,
token_address: &str,
time_last: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/token/top_flow/{chain}/{token_address}");
let mut q = QueryBuf::new();
q.push("timeLast", time_last);
q.push_opt("chains", chains);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn token_top_flow_by_id(
&self,
id: &str,
time_last: &str,
chains: Option<&str>,
) -> Result<Value, TailFinError> {
let path = format!("/token/top_flow/{id}");
let mut q = QueryBuf::new();
q.push("timeLast", time_last);
q.push_opt("chains", chains);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn token_trending(&self) -> Result<Value, TailFinError> {
self.signed_get("/token/trending", &[]).await
}
pub async fn token_trending_by_id(&self, id: &str) -> Result<Value, TailFinError> {
let path = format!("/token/trending/{id}");
self.signed_get(&path, &[]).await
}
pub async fn token_volume_by_addr(
&self,
chain: &str,
token_address: &str,
time_last: &str,
granularity: &str,
) -> Result<Value, TailFinError> {
let path = format!("/token/volume/{chain}/{token_address}");
let mut q = QueryBuf::new();
q.push("timeLast", time_last);
q.push("granularity", granularity);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn token_volume_by_id(
&self,
id: &str,
time_last: &str,
granularity: &str,
) -> Result<Value, TailFinError> {
let path = format!("/token/volume/{id}");
let mut q = QueryBuf::new();
q.push("timeLast", time_last);
q.push("granularity", granularity);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn token_arkham_exchange_tokens(&self) -> Result<Value, TailFinError> {
self.signed_get("/token/arkham_exchange_tokens", &[]).await
}
pub async fn intelligence_token_by_addr(
&self,
chain: &str,
address: &str,
) -> Result<Value, TailFinError> {
let path = format!("/intelligence/token/{chain}/{address}");
self.signed_get(&path, &[]).await
}
pub async fn intelligence_token_by_id(&self, id: &str) -> Result<Value, TailFinError> {
let path = format!("/intelligence/token/{id}");
self.signed_get(&path, &[]).await
}
pub async fn intelligence_addresses_updates(
&self,
since: Option<&str>,
limit: Option<u32>,
page_token: Option<&str>,
) -> Result<Value, TailFinError> {
let mut q = QueryBuf::new();
q.push_opt("since", since);
q.push_opt_num("limit", limit);
q.push_opt("pageToken", page_token);
self.signed_get("/intelligence/addresses/updates", &q.as_pairs())
.await
}
pub async fn intelligence_entities_updates(
&self,
since: Option<&str>,
limit: Option<u32>,
page_token: Option<&str>,
) -> Result<Value, TailFinError> {
let mut q = QueryBuf::new();
q.push_opt("since", since);
q.push_opt_num("limit", limit);
q.push_opt("pageToken", page_token);
self.signed_get("/intelligence/entities/updates", &q.as_pairs())
.await
}
pub async fn intelligence_tags_updates(
&self,
since: Option<&str>,
limit: Option<u32>,
page_token: Option<&str>,
) -> Result<Value, TailFinError> {
let mut q = QueryBuf::new();
q.push_opt("since", since);
q.push_opt_num("limit", limit);
q.push_opt("pageToken", page_token);
self.signed_get("/intelligence/tags/updates", &q.as_pairs())
.await
}
pub async fn intelligence_address_tags_updates(
&self,
since: Option<&str>,
limit: Option<u32>,
page_token: Option<&str>,
) -> Result<Value, TailFinError> {
let mut q = QueryBuf::new();
q.push_opt("since", since);
q.push_opt_num("limit", limit);
q.push_opt("pageToken", page_token);
self.signed_get("/intelligence/address_tags/updates", &q.as_pairs())
.await
}
pub async fn transfers_histogram(
&self,
q: &TransfersQuery<'_>,
granularity: Option<&str>,
) -> Result<Value, TailFinError> {
let mut pairs = transfers_query_pairs(q);
if let Some(g) = granularity {
pairs.push(("granularity", g.to_string()));
}
let pair_refs: Vec<(&str, &str)> = pairs.iter().map(|(k, v)| (*k, v.as_str())).collect();
self.signed_get("/transfers/histogram", &pair_refs).await
}
pub async fn transfers_histogram_simple(
&self,
q: &TransfersQuery<'_>,
) -> Result<Value, TailFinError> {
let pairs = transfers_query_pairs(q);
let pair_refs: Vec<(&str, &str)> = pairs.iter().map(|(k, v)| (*k, v.as_str())).collect();
self.signed_get("/transfers/histogram/simple", &pair_refs)
.await
}
pub async fn transfers_tx(
&self,
hash: &str,
transfer_type: &str,
chain: &str,
) -> Result<Value, TailFinError> {
let path = format!("/transfers/tx/{hash}");
let mut q = QueryBuf::new();
q.push("transferType", transfer_type);
q.push("chain", chain);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn tx(&self, hash: &str) -> Result<Value, TailFinError> {
let path = format!("/tx/{hash}");
self.signed_get(&path, &[]).await
}
pub async fn swaps(
&self,
base: Option<&[&str]>,
chains: Option<&str>,
flow: Option<&str>,
time_last: Option<&str>,
usd_gte: Option<&str>,
sort_key: Option<&str>,
sort_dir: Option<&str>,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<Value, TailFinError> {
let mut q = QueryBuf::new();
if let Some(bs) = base {
for b in bs {
q.push("base", b);
}
}
q.push_opt("chains", chains);
q.push_opt("flow", flow);
q.push_opt("timeLast", time_last);
q.push_opt("usdGte", usd_gte);
q.push_opt("sortKey", sort_key);
q.push_opt("sortDir", sort_dir);
q.push_opt_num("limit", limit);
q.push_opt_num("offset", offset);
self.signed_get("/swaps", &q.as_pairs()).await
}
pub async fn cluster_summary(&self, id: &str) -> Result<Value, TailFinError> {
let path = format!("/cluster/{id}/summary");
self.signed_get(&path, &[]).await
}
pub async fn tag_params(
&self,
id: &str,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<Value, TailFinError> {
let path = format!("/tag/{id}/params");
let mut q = QueryBuf::new();
q.push_opt_num("limit", limit);
q.push_opt_num("offset", offset);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn tag_summary(&self, id: &str) -> Result<Value, TailFinError> {
let path = format!("/tag/{id}/summary");
self.signed_get(&path, &[]).await
}
pub async fn balances_solana_subaccounts_address(
&self,
addresses: &str,
pricing_id: &str,
limit: Option<u32>,
) -> Result<Value, TailFinError> {
let path = format!("/balances/solana/subaccounts/address/{addresses}");
let mut q = QueryBuf::new();
q.push("pricingID", pricing_id);
q.push_opt_num("limit", limit);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn balances_solana_subaccounts_entity(
&self,
entities: &str,
pricing_id: &str,
limit: Option<u32>,
) -> Result<Value, TailFinError> {
let path = format!("/balances/solana/subaccounts/entity/{entities}");
let mut q = QueryBuf::new();
q.push("pricingID", pricing_id);
q.push_opt_num("limit", limit);
self.signed_get(&path, &q.as_pairs()).await
}
pub async fn chains(&self) -> Result<Value, TailFinError> {
self.signed_get("/chains", &[]).await
}
pub async fn networks_status(&self) -> Result<Value, TailFinError> {
self.signed_get("/networks/status", &[]).await
}
pub async fn networks_history(&self, chain: &str) -> Result<Value, TailFinError> {
let path = format!("/networks/history/{chain}");
self.signed_get(&path, &[]).await
}
pub async fn altcoin_index(&self) -> Result<Value, TailFinError> {
self.signed_get("/marketdata/altcoin_index", &[]).await
}
pub async fn arkm_circulating(&self) -> Result<Value, TailFinError> {
self.signed_get("/arkm/circulating", &[]).await
}
}
fn current_unix_seconds() -> Result<String, TailFinError> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.map_err(|e| TailFinError::Api(format!("system clock before epoch: {e}")))
}
fn build_signed_headers(ts: &str, payload: &str) -> Result<HeaderMap, TailFinError> {
let mut h = HeaderMap::new();
let to_hv = |s: &str, name: &'static str| {
HeaderValue::from_str(s)
.map_err(|e| TailFinError::Api(format!("invalid {name} header value: {e}")))
};
h.insert(
ACCEPT,
HeaderValue::from_static("application/json, text/plain, */*"),
);
h.insert(ORIGIN, HeaderValue::from_static(ORIGIN_HEADER));
h.insert(REFERER, HeaderValue::from_static(REFERER_HEADER));
h.insert("X-Timestamp", to_hv(ts, "X-Timestamp")?);
h.insert("X-Payload", to_hv(payload, "X-Payload")?);
Ok(h)
}
fn chains_pairs(chains: Option<&str>) -> Vec<(&str, &str)> {
chains.map(|c| vec![("chains", c)]).unwrap_or_default()
}
struct QueryBuf {
pairs: Vec<(&'static str, String)>,
}
impl QueryBuf {
fn new() -> Self {
Self { pairs: Vec::new() }
}
fn push(&mut self, k: &'static str, v: &str) {
self.pairs.push((k, v.to_string()));
}
fn push_opt(&mut self, k: &'static str, v: Option<&str>) {
if let Some(s) = v {
self.pairs.push((k, s.to_string()));
}
}
fn push_opt_bool(&mut self, k: &'static str, v: Option<bool>) {
if let Some(b) = v {
self.pairs
.push((k, if b { "true".into() } else { "false".into() }));
}
}
fn push_opt_num<N: std::fmt::Display>(&mut self, k: &'static str, v: Option<N>) {
if let Some(n) = v {
self.pairs.push((k, n.to_string()));
}
}
fn as_pairs(&self) -> Vec<(&'static str, &str)> {
self.pairs.iter().map(|(k, v)| (*k, v.as_str())).collect()
}
}
fn transfers_query_pairs(q: &TransfersQuery<'_>) -> Vec<(&'static str, String)> {
let mut out: Vec<(&'static str, String)> = Vec::new();
if let Some(bases) = q.base {
for b in bases {
out.push(("base", (*b).to_string()));
}
}
if let Some(f) = q.flow {
out.push(("flow", f.to_string()));
}
if let Some(v) = q.usd_gte {
out.push(("usdGte", v.to_string()));
}
if let Some(k) = q.sort_key {
out.push(("sortKey", k.to_string()));
}
if let Some(d) = q.sort_dir {
out.push(("sortDir", d.to_string()));
}
if let Some(l) = q.limit {
out.push(("limit", l.to_string()));
}
if let Some(o) = q.offset {
out.push(("offset", o.to_string()));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transfers_query_pairs_empty_when_default() {
assert!(transfers_query_pairs(&TransfersQuery::default()).is_empty());
}
#[test]
fn transfers_query_pairs_repeats_base() {
let bases = ["0xaaa", "0xbbb"];
let q = TransfersQuery {
base: Some(&bases),
flow: Some("all"),
usd_gte: Some("1"),
sort_key: Some("time"),
sort_dir: Some("desc"),
limit: Some(16),
offset: Some(0),
};
let owned = transfers_query_pairs(&q);
let pairs: Vec<(&str, &str)> = owned.iter().map(|(k, v)| (*k, v.as_str())).collect();
assert_eq!(
pairs,
vec![
("base", "0xaaa"),
("base", "0xbbb"),
("flow", "all"),
("usdGte", "1"),
("sortKey", "time"),
("sortDir", "desc"),
("limit", "16"),
("offset", "0"),
]
);
}
#[test]
fn querybuf_skips_none() {
let mut q = QueryBuf::new();
q.push_opt("a", None);
q.push_opt("b", Some("yes"));
q.push_opt_num::<u32>("c", None);
q.push_opt_num("d", Some(42_u32));
q.push_opt_bool("e", None);
q.push_opt_bool("f", Some(true));
let v = q.as_pairs();
assert_eq!(v, vec![("b", "yes"), ("d", "42"), ("f", "true")]);
}
}