Skip to main content

tail_fin_arkham/
http.rs

1//! HTTP client for `api.arkm.com`.
2//!
3//! Pure wreq — no browser, no cookies. See `signing` for the X-Payload
4//! algorithm and the module-level docs in `lib.rs` for context.
5//!
6//! Most endpoints return `serde_json::Value` because Arkham's full schema
7//! catalogue (101 schemas across 84 paths, with deeply nested oneOfs and
8//! example-only fields) is impractical to type by hand. The three highest-
9//! value endpoints (`search`, `address_enriched`, `transfers`) are typed
10//! into named structs in `types.rs`. Library users who want types for the
11//! rest can `serde_json::from_value` into their own structs.
12
13use serde_json::Value;
14use std::time::{SystemTime, UNIX_EPOCH};
15use tail_fin_common::TailFinError;
16use wreq::header::{HeaderMap, HeaderValue, ACCEPT, ORIGIN, REFERER};
17
18use crate::parsing::{parse_address_enriched, parse_search_results, parse_transfers};
19use crate::signing::sign_payload;
20use crate::types::{AddressEnriched, SearchResults, TransfersPage, TransfersQuery};
21
22const API_BASE: &str = "https://api.arkm.com";
23const ORIGIN_HEADER: &str = "https://intel.arkm.com";
24const REFERER_HEADER: &str = "https://intel.arkm.com/";
25
26/// Default opt-ins for `intelligence/address_enriched/*` endpoints — the
27/// SPA always sends these three with `true` and reducing them dilutes the
28/// response, so we default the same way.
29const ADDRESS_ENRICHED_INCLUDES: &[(&str, &str)] = &[
30    ("includeTags", "true"),
31    ("includeEntityPredictions", "true"),
32    ("includeClusters", "true"),
33];
34
35/// HTTP client for Arkham Intel's public data API.
36pub struct ArkhamClient {
37    client: wreq::Client,
38}
39
40// `clippy::too_many_arguments` flags several methods (counterparties_*,
41// swaps, token_top, intelligence_entity_balance_changes) that mirror the
42// upstream OpenAPI surface 1:1. Splitting those into builder structs would
43// add a layer that doesn't earn its keep on a v0 spike — keep the call
44// sites flat and let callers pass `None` for unused filters.
45#[allow(clippy::too_many_arguments)]
46impl ArkhamClient {
47    /// Build a Chrome-145-emulated client. The TLS fingerprint is overkill
48    /// for `api.arkm.com` (vanilla curl works), but keeps us aligned with the
49    /// SPA and inoculates against future Cloudflare tightening.
50    pub fn new() -> Result<Self, TailFinError> {
51        let emu = wreq_util::EmulationOption::builder()
52            .emulation(wreq_util::Emulation::Chrome145)
53            .emulation_os(wreq_util::EmulationOS::MacOS)
54            .build();
55        let client = wreq::Client::builder()
56            .emulation(emu)
57            .connect_timeout(std::time::Duration::from_secs(10))
58            .timeout(std::time::Duration::from_secs(30))
59            .build()
60            .map_err(|e| TailFinError::Api(format!("failed to build HTTP client: {e}")))?;
61        Ok(Self { client })
62    }
63
64    // ─── building block ──────────────────────────────────────────────────
65    //
66    // Every public endpoint goes through `signed_get`. `path` is the URL
67    // pathname (used for both the URL and the X-Payload signature — they
68    // MUST match, query string is excluded from signing per the SPA's
69    // `new URL(t).pathname` extraction). `query_pairs` becomes the URL's
70    // `?key=val&key=val`. Multi-value params (e.g. `base=` repeated) pass
71    // multiple pairs with the same key.
72
73    /// Call any signed GET endpoint and return the response body as `Value`.
74    /// Public so library users can hit endpoints we haven't typed yet.
75    pub async fn signed_get(
76        &self,
77        path: &str,
78        query_pairs: &[(&str, &str)],
79    ) -> Result<Value, TailFinError> {
80        let url = if query_pairs.is_empty() {
81            format!("{API_BASE}{path}")
82        } else {
83            let qs = query_pairs
84                .iter()
85                .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
86                .collect::<Vec<_>>()
87                .join("&");
88            format!("{API_BASE}{path}?{qs}")
89        };
90        self.send_signed(&url, path).await
91    }
92
93    async fn send_signed(&self, url: &str, path: &str) -> Result<Value, TailFinError> {
94        let ts = current_unix_seconds()?;
95        let payload = sign_payload(path, &ts);
96        let headers = build_signed_headers(&ts, &payload)?;
97
98        let resp = self
99            .client
100            .get(url)
101            .headers(headers)
102            .send()
103            .await
104            .map_err(|e| TailFinError::Api(format!("Arkham GET {path} failed: {e}")))?;
105
106        let status = resp.status();
107        let body_bytes = resp
108            .bytes()
109            .await
110            .map_err(|e| TailFinError::Api(format!("Arkham GET {path} body read failed: {e}")))?;
111
112        if !status.is_success() {
113            let preview = String::from_utf8_lossy(&body_bytes);
114            return Err(TailFinError::Api(format!(
115                "Arkham GET {path} HTTP {}: {}",
116                status.as_u16(),
117                preview.chars().take(300).collect::<String>()
118            )));
119        }
120
121        serde_json::from_slice::<Value>(&body_bytes)
122            .map_err(|e| TailFinError::Api(format!("Arkham GET {path} returned non-JSON: {e}")))
123    }
124
125    // ─── typed: search / address_enriched / transfers ─────────────────────
126
127    /// Search across entities, tokens, and pools.
128    pub async fn search(&self, query: &str) -> Result<SearchResults, TailFinError> {
129        let body = self
130            .signed_get("/intelligence/search", &[("query", query)])
131            .await?;
132        parse_search_results(&body)
133    }
134
135    /// Cross-chain enriched profile for an EVM-style address.
136    pub async fn address_enriched(&self, address: &str) -> Result<AddressEnriched, TailFinError> {
137        let path = format!("/intelligence/address_enriched/{address}/all");
138        let body = self.signed_get(&path, ADDRESS_ENRICHED_INCLUDES).await?;
139        parse_address_enriched(&body)
140    }
141
142    /// Page of transfers matching the filter. Pass [`TransfersQuery::default`]
143    /// for a default scan, or populate fields like `base`, `flow`, `usd_gte`,
144    /// `sort_key`/`sort_dir`, `limit`, `offset` to filter and paginate.
145    pub async fn transfers(&self, q: &TransfersQuery<'_>) -> Result<TransfersPage, TailFinError> {
146        let pairs = transfers_query_pairs(q);
147        let pair_refs: Vec<(&str, &str)> = pairs.iter().map(|(k, v)| (*k, v.as_str())).collect();
148        let body = self.signed_get("/transfers", &pair_refs).await?;
149        parse_transfers(&body)
150    }
151
152    // ─── address-keyed mirrors ───────────────────────────────────────────
153
154    pub async fn balances_address(
155        &self,
156        address: &str,
157        chains: Option<&str>,
158    ) -> Result<Value, TailFinError> {
159        let path = format!("/balances/address/{address}");
160        self.signed_get(&path, &chains_pairs(chains)).await
161    }
162
163    pub async fn counterparties_address(
164        &self,
165        address: &str,
166        chains: Option<&str>,
167        flow: Option<&str>,
168        time_last: Option<&str>,
169        usd_gte: Option<&str>,
170        limit: Option<u32>,
171    ) -> Result<Value, TailFinError> {
172        let path = format!("/counterparties/address/{address}");
173        let mut q = QueryBuf::new();
174        q.push_opt("chains", chains);
175        q.push_opt("flow", flow);
176        q.push_opt("timeLast", time_last);
177        q.push_opt("usdGte", usd_gte);
178        q.push_opt_num("limit", limit);
179        self.signed_get(&path, &q.as_pairs()).await
180    }
181
182    pub async fn flow_address(
183        &self,
184        address: &str,
185        chains: Option<&str>,
186    ) -> Result<Value, TailFinError> {
187        let path = format!("/flow/address/{address}");
188        self.signed_get(&path, &chains_pairs(chains)).await
189    }
190
191    pub async fn history_address(
192        &self,
193        address: &str,
194        chains: Option<&str>,
195    ) -> Result<Value, TailFinError> {
196        let path = format!("/history/address/{address}");
197        self.signed_get(&path, &chains_pairs(chains)).await
198    }
199
200    /// Single-chain `/intelligence/address/{address}` (NOT the `/all` variant).
201    pub async fn intelligence_address(
202        &self,
203        address: &str,
204        chain: &str,
205    ) -> Result<Value, TailFinError> {
206        let path = format!("/intelligence/address/{address}");
207        self.signed_get(&path, &[("chain", chain)]).await
208    }
209
210    /// All-chain `/intelligence/address/{address}/all`.
211    pub async fn intelligence_address_all(&self, address: &str) -> Result<Value, TailFinError> {
212        let path = format!("/intelligence/address/{address}/all");
213        self.signed_get(&path, &[]).await
214    }
215
216    /// Single-chain enriched profile (vs `address_enriched()` which is `/all`).
217    pub async fn intelligence_address_enriched_chain(
218        &self,
219        address: &str,
220        chain: &str,
221    ) -> Result<Value, TailFinError> {
222        let path = format!("/intelligence/address_enriched/{address}");
223        let mut pairs = vec![("chain", chain)];
224        pairs.extend_from_slice(ADDRESS_ENRICHED_INCLUDES);
225        self.signed_get(&path, &pairs).await
226    }
227
228    pub async fn intelligence_contract(
229        &self,
230        chain: &str,
231        address: &str,
232    ) -> Result<Value, TailFinError> {
233        let path = format!("/intelligence/contract/{chain}/{address}");
234        self.signed_get(&path, &[]).await
235    }
236
237    pub async fn loans_address(
238        &self,
239        address: &str,
240        chains: Option<&str>,
241    ) -> Result<Value, TailFinError> {
242        let path = format!("/loans/address/{address}");
243        self.signed_get(&path, &chains_pairs(chains)).await
244    }
245
246    /// Server REQUIRES `time` (unix milliseconds string). Returns 400
247    /// "invalid unix millisecond time" without it.
248    pub async fn portfolio_address(
249        &self,
250        address: &str,
251        time: &str,
252        chains: Option<&str>,
253    ) -> Result<Value, TailFinError> {
254        let path = format!("/portfolio/address/{address}");
255        let mut q = QueryBuf::new();
256        q.push("time", time);
257        q.push_opt("chains", chains);
258        self.signed_get(&path, &q.as_pairs()).await
259    }
260
261    /// Server REQUIRES `pricing_id`. Returns 400 "missing pricingId" without it.
262    pub async fn portfolio_timeseries_address(
263        &self,
264        address: &str,
265        pricing_id: &str,
266        chains: Option<&str>,
267    ) -> Result<Value, TailFinError> {
268        let path = format!("/portfolio/timeSeries/address/{address}");
269        let mut q = QueryBuf::new();
270        q.push("pricingId", pricing_id);
271        q.push_opt("chains", chains);
272        self.signed_get(&path, &q.as_pairs()).await
273    }
274
275    pub async fn volume_address(
276        &self,
277        address: &str,
278        chains: Option<&str>,
279    ) -> Result<Value, TailFinError> {
280        let path = format!("/volume/address/{address}");
281        self.signed_get(&path, &chains_pairs(chains)).await
282    }
283
284    // ─── entity-keyed mirrors ────────────────────────────────────────────
285
286    pub async fn balances_entity(
287        &self,
288        entity: &str,
289        chains: Option<&str>,
290        cheap: Option<bool>,
291    ) -> Result<Value, TailFinError> {
292        let path = format!("/balances/entity/{entity}");
293        let mut q = QueryBuf::new();
294        q.push_opt("chains", chains);
295        q.push_opt_bool("cheap", cheap);
296        self.signed_get(&path, &q.as_pairs()).await
297    }
298
299    pub async fn counterparties_entity(
300        &self,
301        entity: &str,
302        chains: Option<&str>,
303        flow: Option<&str>,
304        time_last: Option<&str>,
305        usd_gte: Option<&str>,
306        limit: Option<u32>,
307    ) -> Result<Value, TailFinError> {
308        let path = format!("/counterparties/entity/{entity}");
309        let mut q = QueryBuf::new();
310        q.push_opt("chains", chains);
311        q.push_opt("flow", flow);
312        q.push_opt("timeLast", time_last);
313        q.push_opt("usdGte", usd_gte);
314        q.push_opt_num("limit", limit);
315        self.signed_get(&path, &q.as_pairs()).await
316    }
317
318    pub async fn flow_entity(
319        &self,
320        entity: &str,
321        chains: Option<&str>,
322    ) -> Result<Value, TailFinError> {
323        let path = format!("/flow/entity/{entity}");
324        self.signed_get(&path, &chains_pairs(chains)).await
325    }
326
327    pub async fn history_entity(
328        &self,
329        entity: &str,
330        chains: Option<&str>,
331    ) -> Result<Value, TailFinError> {
332        let path = format!("/history/entity/{entity}");
333        self.signed_get(&path, &chains_pairs(chains)).await
334    }
335
336    pub async fn loans_entity(
337        &self,
338        entity: &str,
339        chains: Option<&str>,
340    ) -> Result<Value, TailFinError> {
341        let path = format!("/loans/entity/{entity}");
342        self.signed_get(&path, &chains_pairs(chains)).await
343    }
344
345    /// Server REQUIRES `time` (unix milliseconds string). Returns 400
346    /// "invalid unix millisecond time" without it.
347    pub async fn portfolio_entity(
348        &self,
349        entity: &str,
350        time: &str,
351        chains: Option<&str>,
352    ) -> Result<Value, TailFinError> {
353        let path = format!("/portfolio/entity/{entity}");
354        let mut q = QueryBuf::new();
355        q.push("time", time);
356        q.push_opt("chains", chains);
357        self.signed_get(&path, &q.as_pairs()).await
358    }
359
360    /// Server REQUIRES `pricing_id`. Returns 400 "missing pricingId" without it.
361    pub async fn portfolio_timeseries_entity(
362        &self,
363        entity: &str,
364        pricing_id: &str,
365        chains: Option<&str>,
366    ) -> Result<Value, TailFinError> {
367        let path = format!("/portfolio/timeSeries/entity/{entity}");
368        let mut q = QueryBuf::new();
369        q.push("pricingId", pricing_id);
370        q.push_opt("chains", chains);
371        self.signed_get(&path, &q.as_pairs()).await
372    }
373
374    pub async fn volume_entity(
375        &self,
376        entity: &str,
377        chains: Option<&str>,
378    ) -> Result<Value, TailFinError> {
379        let path = format!("/volume/entity/{entity}");
380        self.signed_get(&path, &chains_pairs(chains)).await
381    }
382
383    pub async fn intelligence_entity(&self, entity: &str) -> Result<Value, TailFinError> {
384        let path = format!("/intelligence/entity/{entity}");
385        self.signed_get(&path, &[]).await
386    }
387
388    pub async fn intelligence_entity_summary(&self, entity: &str) -> Result<Value, TailFinError> {
389        let path = format!("/intelligence/entity/{entity}/summary");
390        self.signed_get(&path, &[]).await
391    }
392
393    pub async fn intelligence_entity_predictions(
394        &self,
395        entity: &str,
396    ) -> Result<Value, TailFinError> {
397        let path = format!("/intelligence/entity_predictions/{entity}");
398        self.signed_get(&path, &[]).await
399    }
400
401    /// Recent balance changes across entities. Server REQUIRES four params
402    /// — `order_by`, `order_dir`, `interval`, and `limit`. Without `interval`
403    /// the server returns 400; without `limit` it returns 500. Valid
404    /// `order_by`: `balanceUsd | balanceUsdChange | balanceUsdPctChange |
405    /// balanceUnit | balanceUnitChange | balanceUnitPctChange`. Valid
406    /// `interval`: `7d | 14d | 30d`.
407    pub async fn intelligence_entity_balance_changes(
408        &self,
409        order_by: &str,
410        order_dir: &str,
411        interval: &str,
412        limit: u32,
413        chains: Option<&str>,
414        entity_types: Option<&str>,
415        entity_ids: Option<&str>,
416        offset: Option<u32>,
417    ) -> Result<Value, TailFinError> {
418        let mut q = QueryBuf::new();
419        q.push("orderBy", order_by);
420        q.push("orderDir", order_dir);
421        q.push("interval", interval);
422        q.push_opt_num("limit", Some(limit));
423        q.push_opt("chains", chains);
424        q.push_opt("entityTypes", entity_types);
425        q.push_opt("entityIds", entity_ids);
426        q.push_opt_num("offset", offset);
427        self.signed_get("/intelligence/entity_balance_changes", &q.as_pairs())
428            .await
429    }
430
431    pub async fn intelligence_entity_types(&self) -> Result<Value, TailFinError> {
432        self.signed_get("/intelligence/entity_types", &[]).await
433    }
434
435    // ─── token endpoints ─────────────────────────────────────────────────
436
437    pub async fn token_addresses(&self, id: &str) -> Result<Value, TailFinError> {
438        let path = format!("/token/addresses/{id}");
439        self.signed_get(&path, &[]).await
440    }
441
442    pub async fn token_balance_by_addr(
443        &self,
444        chain: &str,
445        token_address: &str,
446        entity_id: Option<&str>,
447        holder_address: Option<&str>,
448    ) -> Result<Value, TailFinError> {
449        let path = format!("/token/balance/{chain}/{token_address}");
450        let mut q = QueryBuf::new();
451        q.push_opt("entityID", entity_id);
452        q.push_opt("address", holder_address);
453        self.signed_get(&path, &q.as_pairs()).await
454    }
455
456    pub async fn token_balance_by_id(
457        &self,
458        id: &str,
459        entity_id: Option<&str>,
460        holder_address: Option<&str>,
461    ) -> Result<Value, TailFinError> {
462        let path = format!("/token/balance/{id}");
463        let mut q = QueryBuf::new();
464        q.push_opt("entityID", entity_id);
465        q.push_opt("address", holder_address);
466        self.signed_get(&path, &q.as_pairs()).await
467    }
468
469    pub async fn token_holders_by_addr(
470        &self,
471        chain: &str,
472        token_address: &str,
473        group_by_entity: Option<bool>,
474        limit: Option<u32>,
475        offset: Option<u32>,
476    ) -> Result<Value, TailFinError> {
477        let path = format!("/token/holders/{chain}/{token_address}");
478        let mut q = QueryBuf::new();
479        q.push_opt_bool("groupByEntity", group_by_entity);
480        q.push_opt_num("limit", limit);
481        q.push_opt_num("offset", offset);
482        self.signed_get(&path, &q.as_pairs()).await
483    }
484
485    pub async fn token_holders_by_id(
486        &self,
487        id: &str,
488        group_by_entity: Option<bool>,
489        limit: Option<u32>,
490        offset: Option<u32>,
491    ) -> Result<Value, TailFinError> {
492        let path = format!("/token/holders/{id}");
493        let mut q = QueryBuf::new();
494        q.push_opt_bool("groupByEntity", group_by_entity);
495        q.push_opt_num("limit", limit);
496        q.push_opt_num("offset", offset);
497        self.signed_get(&path, &q.as_pairs()).await
498    }
499
500    pub async fn token_market(&self, id: &str) -> Result<Value, TailFinError> {
501        let path = format!("/token/market/{id}");
502        self.signed_get(&path, &[]).await
503    }
504
505    pub async fn token_price_history_by_addr(
506        &self,
507        chain: &str,
508        token_address: &str,
509        daily: Option<bool>,
510    ) -> Result<Value, TailFinError> {
511        let path = format!("/token/price/history/{chain}/{token_address}");
512        let mut q = QueryBuf::new();
513        q.push_opt_bool("daily", daily);
514        self.signed_get(&path, &q.as_pairs()).await
515    }
516
517    pub async fn token_price_history_by_id(
518        &self,
519        id: &str,
520        daily: Option<bool>,
521    ) -> Result<Value, TailFinError> {
522        let path = format!("/token/price/history/{id}");
523        let mut q = QueryBuf::new();
524        q.push_opt_bool("daily", daily);
525        self.signed_get(&path, &q.as_pairs()).await
526    }
527
528    /// Server REQUIRES `past_time` as an **RFC 3339 timestamp** (e.g.
529    /// `2025-12-01T00:00:00Z`) — NOT a Go duration. Server returns 400
530    /// "missing pastTime query param" without it, "invalid pastTime format"
531    /// with anything that doesn't parse as RFC 3339.
532    pub async fn token_price_change(
533        &self,
534        id: &str,
535        past_time: &str,
536    ) -> Result<Value, TailFinError> {
537        let path = format!("/token/price_change/{id}");
538        let mut q = QueryBuf::new();
539        q.push("pastTime", past_time);
540        self.signed_get(&path, &q.as_pairs()).await
541    }
542
543    /// Top tokens. Server REQUIRES four params — `timeframe`,
544    /// `order_by_agg`, `order_by_percent`, `from`, `order_by_desc`, AND
545    /// `size` — six required params total. Server returns 400 naming
546    /// whichever is missing. `from` is the pagination start (0 for the
547    /// first page). Note `order_by_agg` here is distinct from the
548    /// `order_by` param on `/intelligence/entity_balance_changes`.
549    pub async fn token_top(
550        &self,
551        timeframe: &str,
552        order_by_agg: &str,
553        order_by_percent: bool,
554        order_by_desc: bool,
555        from: u32,
556        size: u32,
557        chains: Option<&str>,
558    ) -> Result<Value, TailFinError> {
559        let mut q = QueryBuf::new();
560        q.push("timeframe", timeframe);
561        q.push("orderByAgg", order_by_agg);
562        q.push(
563            "orderByPercent",
564            if order_by_percent { "true" } else { "false" },
565        );
566        q.push("orderByDesc", if order_by_desc { "true" } else { "false" });
567        q.push_opt_num("from", Some(from));
568        q.push_opt_num("size", Some(size));
569        q.push_opt("chains", chains);
570        self.signed_get("/token/top", &q.as_pairs()).await
571    }
572
573    /// Server REQUIRES `time_last` (Go duration: `1h`, `24h`, `7d`, `30d`).
574    /// Returns 400 "invalid timeLast" without it.
575    pub async fn token_top_flow_by_addr(
576        &self,
577        chain: &str,
578        token_address: &str,
579        time_last: &str,
580        chains: Option<&str>,
581    ) -> Result<Value, TailFinError> {
582        let path = format!("/token/top_flow/{chain}/{token_address}");
583        let mut q = QueryBuf::new();
584        q.push("timeLast", time_last);
585        q.push_opt("chains", chains);
586        self.signed_get(&path, &q.as_pairs()).await
587    }
588
589    /// Server REQUIRES `time_last`. See [`token_top_flow_by_addr`].
590    pub async fn token_top_flow_by_id(
591        &self,
592        id: &str,
593        time_last: &str,
594        chains: Option<&str>,
595    ) -> Result<Value, TailFinError> {
596        let path = format!("/token/top_flow/{id}");
597        let mut q = QueryBuf::new();
598        q.push("timeLast", time_last);
599        q.push_opt("chains", chains);
600        self.signed_get(&path, &q.as_pairs()).await
601    }
602
603    pub async fn token_trending(&self) -> Result<Value, TailFinError> {
604        self.signed_get("/token/trending", &[]).await
605    }
606
607    pub async fn token_trending_by_id(&self, id: &str) -> Result<Value, TailFinError> {
608        let path = format!("/token/trending/{id}");
609        self.signed_get(&path, &[]).await
610    }
611
612    /// Server REQUIRES `granularity` as a Go duration string (`1h`, `1d`,
613    /// `30m`, etc. — NOT keywords like `hourly`/`daily`). Returns 400
614    /// "missing granularity" or "invalid duration" otherwise. `time_last`
615    /// is also de-facto required by the server.
616    pub async fn token_volume_by_addr(
617        &self,
618        chain: &str,
619        token_address: &str,
620        time_last: &str,
621        granularity: &str,
622    ) -> Result<Value, TailFinError> {
623        let path = format!("/token/volume/{chain}/{token_address}");
624        let mut q = QueryBuf::new();
625        q.push("timeLast", time_last);
626        q.push("granularity", granularity);
627        self.signed_get(&path, &q.as_pairs()).await
628    }
629
630    /// Server REQUIRES `granularity` and `time_last`. See [`token_volume_by_addr`].
631    pub async fn token_volume_by_id(
632        &self,
633        id: &str,
634        time_last: &str,
635        granularity: &str,
636    ) -> Result<Value, TailFinError> {
637        let path = format!("/token/volume/{id}");
638        let mut q = QueryBuf::new();
639        q.push("timeLast", time_last);
640        q.push("granularity", granularity);
641        self.signed_get(&path, &q.as_pairs()).await
642    }
643
644    pub async fn token_arkham_exchange_tokens(&self) -> Result<Value, TailFinError> {
645        self.signed_get("/token/arkham_exchange_tokens", &[]).await
646    }
647
648    pub async fn intelligence_token_by_addr(
649        &self,
650        chain: &str,
651        address: &str,
652    ) -> Result<Value, TailFinError> {
653        let path = format!("/intelligence/token/{chain}/{address}");
654        self.signed_get(&path, &[]).await
655    }
656
657    pub async fn intelligence_token_by_id(&self, id: &str) -> Result<Value, TailFinError> {
658        let path = format!("/intelligence/token/{id}");
659        self.signed_get(&path, &[]).await
660    }
661
662    // ─── updates feeds ───────────────────────────────────────────────────
663
664    pub async fn intelligence_addresses_updates(
665        &self,
666        since: Option<&str>,
667        limit: Option<u32>,
668        page_token: Option<&str>,
669    ) -> Result<Value, TailFinError> {
670        let mut q = QueryBuf::new();
671        q.push_opt("since", since);
672        q.push_opt_num("limit", limit);
673        q.push_opt("pageToken", page_token);
674        self.signed_get("/intelligence/addresses/updates", &q.as_pairs())
675            .await
676    }
677
678    pub async fn intelligence_entities_updates(
679        &self,
680        since: Option<&str>,
681        limit: Option<u32>,
682        page_token: Option<&str>,
683    ) -> Result<Value, TailFinError> {
684        let mut q = QueryBuf::new();
685        q.push_opt("since", since);
686        q.push_opt_num("limit", limit);
687        q.push_opt("pageToken", page_token);
688        self.signed_get("/intelligence/entities/updates", &q.as_pairs())
689            .await
690    }
691
692    pub async fn intelligence_tags_updates(
693        &self,
694        since: Option<&str>,
695        limit: Option<u32>,
696        page_token: Option<&str>,
697    ) -> Result<Value, TailFinError> {
698        let mut q = QueryBuf::new();
699        q.push_opt("since", since);
700        q.push_opt_num("limit", limit);
701        q.push_opt("pageToken", page_token);
702        self.signed_get("/intelligence/tags/updates", &q.as_pairs())
703            .await
704    }
705
706    pub async fn intelligence_address_tags_updates(
707        &self,
708        since: Option<&str>,
709        limit: Option<u32>,
710        page_token: Option<&str>,
711    ) -> Result<Value, TailFinError> {
712        let mut q = QueryBuf::new();
713        q.push_opt("since", since);
714        q.push_opt_num("limit", limit);
715        q.push_opt("pageToken", page_token);
716        self.signed_get("/intelligence/address_tags/updates", &q.as_pairs())
717            .await
718    }
719
720    // ─── transfers extras + tx ───────────────────────────────────────────
721
722    pub async fn transfers_histogram(
723        &self,
724        q: &TransfersQuery<'_>,
725        granularity: Option<&str>,
726    ) -> Result<Value, TailFinError> {
727        let mut pairs = transfers_query_pairs(q);
728        if let Some(g) = granularity {
729            pairs.push(("granularity", g.to_string()));
730        }
731        let pair_refs: Vec<(&str, &str)> = pairs.iter().map(|(k, v)| (*k, v.as_str())).collect();
732        self.signed_get("/transfers/histogram", &pair_refs).await
733    }
734
735    pub async fn transfers_histogram_simple(
736        &self,
737        q: &TransfersQuery<'_>,
738    ) -> Result<Value, TailFinError> {
739        let pairs = transfers_query_pairs(q);
740        let pair_refs: Vec<(&str, &str)> = pairs.iter().map(|(k, v)| (*k, v.as_str())).collect();
741        self.signed_get("/transfers/histogram/simple", &pair_refs)
742            .await
743    }
744
745    /// Server REQUIRES `chain`. Returns 400 "could not parse chain" without it.
746    pub async fn transfers_tx(
747        &self,
748        hash: &str,
749        transfer_type: &str,
750        chain: &str,
751    ) -> Result<Value, TailFinError> {
752        let path = format!("/transfers/tx/{hash}");
753        let mut q = QueryBuf::new();
754        q.push("transferType", transfer_type);
755        q.push("chain", chain);
756        self.signed_get(&path, &q.as_pairs()).await
757    }
758
759    pub async fn tx(&self, hash: &str) -> Result<Value, TailFinError> {
760        let path = format!("/tx/{hash}");
761        self.signed_get(&path, &[]).await
762    }
763
764    /// `base` mirrors `/transfers` (repeated `?base=A&base=B` for multi).
765    pub async fn swaps(
766        &self,
767        base: Option<&[&str]>,
768        chains: Option<&str>,
769        flow: Option<&str>,
770        time_last: Option<&str>,
771        usd_gte: Option<&str>,
772        sort_key: Option<&str>,
773        sort_dir: Option<&str>,
774        limit: Option<u32>,
775        offset: Option<u32>,
776    ) -> Result<Value, TailFinError> {
777        let mut q = QueryBuf::new();
778        if let Some(bs) = base {
779            for b in bs {
780                q.push("base", b);
781            }
782        }
783        q.push_opt("chains", chains);
784        q.push_opt("flow", flow);
785        q.push_opt("timeLast", time_last);
786        q.push_opt("usdGte", usd_gte);
787        q.push_opt("sortKey", sort_key);
788        q.push_opt("sortDir", sort_dir);
789        q.push_opt_num("limit", limit);
790        q.push_opt_num("offset", offset);
791        self.signed_get("/swaps", &q.as_pairs()).await
792    }
793
794    // ─── cluster + tag + solana subaccounts ──────────────────────────────
795
796    pub async fn cluster_summary(&self, id: &str) -> Result<Value, TailFinError> {
797        let path = format!("/cluster/{id}/summary");
798        self.signed_get(&path, &[]).await
799    }
800
801    pub async fn tag_params(
802        &self,
803        id: &str,
804        limit: Option<u32>,
805        offset: Option<u32>,
806    ) -> Result<Value, TailFinError> {
807        let path = format!("/tag/{id}/params");
808        let mut q = QueryBuf::new();
809        q.push_opt_num("limit", limit);
810        q.push_opt_num("offset", offset);
811        self.signed_get(&path, &q.as_pairs()).await
812    }
813
814    pub async fn tag_summary(&self, id: &str) -> Result<Value, TailFinError> {
815        let path = format!("/tag/{id}/summary");
816        self.signed_get(&path, &[]).await
817    }
818
819    pub async fn balances_solana_subaccounts_address(
820        &self,
821        addresses: &str,
822        pricing_id: &str,
823        limit: Option<u32>,
824    ) -> Result<Value, TailFinError> {
825        let path = format!("/balances/solana/subaccounts/address/{addresses}");
826        let mut q = QueryBuf::new();
827        q.push("pricingID", pricing_id);
828        q.push_opt_num("limit", limit);
829        self.signed_get(&path, &q.as_pairs()).await
830    }
831
832    pub async fn balances_solana_subaccounts_entity(
833        &self,
834        entities: &str,
835        pricing_id: &str,
836        limit: Option<u32>,
837    ) -> Result<Value, TailFinError> {
838        let path = format!("/balances/solana/subaccounts/entity/{entities}");
839        let mut q = QueryBuf::new();
840        q.push("pricingID", pricing_id);
841        q.push_opt_num("limit", limit);
842        self.signed_get(&path, &q.as_pairs()).await
843    }
844
845    // ─── metadata / misc ─────────────────────────────────────────────────
846
847    pub async fn chains(&self) -> Result<Value, TailFinError> {
848        self.signed_get("/chains", &[]).await
849    }
850
851    pub async fn networks_status(&self) -> Result<Value, TailFinError> {
852        self.signed_get("/networks/status", &[]).await
853    }
854
855    pub async fn networks_history(&self, chain: &str) -> Result<Value, TailFinError> {
856        let path = format!("/networks/history/{chain}");
857        self.signed_get(&path, &[]).await
858    }
859
860    pub async fn altcoin_index(&self) -> Result<Value, TailFinError> {
861        self.signed_get("/marketdata/altcoin_index", &[]).await
862    }
863
864    pub async fn arkm_circulating(&self) -> Result<Value, TailFinError> {
865        self.signed_get("/arkm/circulating", &[]).await
866    }
867}
868
869// ─── helpers ─────────────────────────────────────────────────────────────
870
871fn current_unix_seconds() -> Result<String, TailFinError> {
872    SystemTime::now()
873        .duration_since(UNIX_EPOCH)
874        .map(|d| d.as_secs().to_string())
875        .map_err(|e| TailFinError::Api(format!("system clock before epoch: {e}")))
876}
877
878fn build_signed_headers(ts: &str, payload: &str) -> Result<HeaderMap, TailFinError> {
879    let mut h = HeaderMap::new();
880    let to_hv = |s: &str, name: &'static str| {
881        HeaderValue::from_str(s)
882            .map_err(|e| TailFinError::Api(format!("invalid {name} header value: {e}")))
883    };
884    h.insert(
885        ACCEPT,
886        HeaderValue::from_static("application/json, text/plain, */*"),
887    );
888    h.insert(ORIGIN, HeaderValue::from_static(ORIGIN_HEADER));
889    h.insert(REFERER, HeaderValue::from_static(REFERER_HEADER));
890    h.insert("X-Timestamp", to_hv(ts, "X-Timestamp")?);
891    h.insert("X-Payload", to_hv(payload, "X-Payload")?);
892    Ok(h)
893}
894
895fn chains_pairs(chains: Option<&str>) -> Vec<(&str, &str)> {
896    chains.map(|c| vec![("chains", c)]).unwrap_or_default()
897}
898
899/// Owned key/value pairs for query strings — used when values are computed
900/// (e.g. `to_string()`'d numbers). Caller borrows back via `as_pairs()`.
901struct QueryBuf {
902    pairs: Vec<(&'static str, String)>,
903}
904
905impl QueryBuf {
906    fn new() -> Self {
907        Self { pairs: Vec::new() }
908    }
909    fn push(&mut self, k: &'static str, v: &str) {
910        self.pairs.push((k, v.to_string()));
911    }
912    fn push_opt(&mut self, k: &'static str, v: Option<&str>) {
913        if let Some(s) = v {
914            self.pairs.push((k, s.to_string()));
915        }
916    }
917    fn push_opt_bool(&mut self, k: &'static str, v: Option<bool>) {
918        if let Some(b) = v {
919            self.pairs
920                .push((k, if b { "true".into() } else { "false".into() }));
921        }
922    }
923    fn push_opt_num<N: std::fmt::Display>(&mut self, k: &'static str, v: Option<N>) {
924        if let Some(n) = v {
925            self.pairs.push((k, n.to_string()));
926        }
927    }
928    fn as_pairs(&self) -> Vec<(&'static str, &str)> {
929        self.pairs.iter().map(|(k, v)| (*k, v.as_str())).collect()
930    }
931}
932
933/// Build the query-pairs for a `/transfers`-style request. Values are owned
934/// because `base[]` is a Vec and counts like `limit` must be stringified.
935fn transfers_query_pairs(q: &TransfersQuery<'_>) -> Vec<(&'static str, String)> {
936    let mut out: Vec<(&'static str, String)> = Vec::new();
937    if let Some(bases) = q.base {
938        for b in bases {
939            out.push(("base", (*b).to_string()));
940        }
941    }
942    if let Some(f) = q.flow {
943        out.push(("flow", f.to_string()));
944    }
945    if let Some(v) = q.usd_gte {
946        out.push(("usdGte", v.to_string()));
947    }
948    if let Some(k) = q.sort_key {
949        out.push(("sortKey", k.to_string()));
950    }
951    if let Some(d) = q.sort_dir {
952        out.push(("sortDir", d.to_string()));
953    }
954    if let Some(l) = q.limit {
955        out.push(("limit", l.to_string()));
956    }
957    if let Some(o) = q.offset {
958        out.push(("offset", o.to_string()));
959    }
960    out
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    #[test]
968    fn transfers_query_pairs_empty_when_default() {
969        assert!(transfers_query_pairs(&TransfersQuery::default()).is_empty());
970    }
971
972    #[test]
973    fn transfers_query_pairs_repeats_base() {
974        let bases = ["0xaaa", "0xbbb"];
975        let q = TransfersQuery {
976            base: Some(&bases),
977            flow: Some("all"),
978            usd_gte: Some("1"),
979            sort_key: Some("time"),
980            sort_dir: Some("desc"),
981            limit: Some(16),
982            offset: Some(0),
983        };
984        let owned = transfers_query_pairs(&q);
985        let pairs: Vec<(&str, &str)> = owned.iter().map(|(k, v)| (*k, v.as_str())).collect();
986        assert_eq!(
987            pairs,
988            vec![
989                ("base", "0xaaa"),
990                ("base", "0xbbb"),
991                ("flow", "all"),
992                ("usdGte", "1"),
993                ("sortKey", "time"),
994                ("sortDir", "desc"),
995                ("limit", "16"),
996                ("offset", "0"),
997            ]
998        );
999    }
1000
1001    #[test]
1002    fn querybuf_skips_none() {
1003        let mut q = QueryBuf::new();
1004        q.push_opt("a", None);
1005        q.push_opt("b", Some("yes"));
1006        q.push_opt_num::<u32>("c", None);
1007        q.push_opt_num("d", Some(42_u32));
1008        q.push_opt_bool("e", None);
1009        q.push_opt_bool("f", Some(true));
1010        let v = q.as_pairs();
1011        assert_eq!(v, vec![("b", "yes"), ("d", "42"), ("f", "true")]);
1012    }
1013}