1use 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
26const ADDRESS_ENRICHED_INCLUDES: &[(&str, &str)] = &[
30 ("includeTags", "true"),
31 ("includeEntityPredictions", "true"),
32 ("includeClusters", "true"),
33];
34
35pub struct ArkhamClient {
37 client: wreq::Client,
38}
39
40#[allow(clippy::too_many_arguments)]
46impl ArkhamClient {
47 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
869fn 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
899struct 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
933fn 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}