tail-fin-arkham 0.7.8

Arkham Intel adapter for tail-fin: pure-HTTP client for api.arkm.com (chain analytics, address profiles, entity search, transfers)
Documentation
//! Typed responses for the public Arkham Intel API surface.
//!
//! Field coverage is intentionally minimal — the OpenAPI spec ships ~101
//! schemas, but most are background detail. The structs here cover what the
//! adapter actually surfaces; they use `serde(default)` and skip unknown
//! fields so partial responses parse cleanly when Arkham adds keys.

use std::collections::HashMap;

use serde::{Deserialize, Serialize};

/// Top-level response of `GET /intelligence/search`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SearchResults {
    /// Curated entities matching the query (exchanges, protocols, funds, …).
    #[serde(rename = "arkhamEntities", default)]
    pub arkham_entities: Vec<Entity>,

    /// Tokens matching the query.
    #[serde(default)]
    pub tokens: Vec<Token>,

    /// Solana DEX pools matching the query.
    #[serde(default)]
    pub pools: Vec<SolanaPool>,
}

/// An Arkham-curated entity (an exchange, protocol, person, fund, etc.).
///
/// All fields are `Option` even where the HAR samples always populated them.
/// Arkham doesn't publish stability guarantees on these field shapes, and
/// schema-drift panics are far worse than a missing field at the use site.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entity {
    #[serde(default)]
    pub id: Option<String>,
    #[serde(default)]
    pub name: Option<String>,
    /// Entity type slug — `cex`, `dex`, `protocol`, `fund`, `miner-validator`,
    /// `individual`, etc. Always present in HAR samples but kept optional for
    /// safety against future schema drift.
    #[serde(default)]
    pub r#type: Option<String>,
    /// Free-form note, often empty.
    #[serde(default)]
    pub note: Option<String>,
    /// Twitter handle (sometimes a bare handle, sometimes a full URL).
    #[serde(default)]
    pub twitter: Option<String>,
    /// `true` if the entity represents a hosted service (CEX, custodian, …).
    #[serde(default)]
    pub service: Option<bool>,
}

/// A token entry in search results.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Token {
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub symbol: Option<String>,
    #[serde(default)]
    pub price: Option<f64>,
    #[serde(rename = "price24hAgo", default)]
    pub price_24h_ago: Option<f64>,
    #[serde(default)]
    pub identifier: Option<TokenIdentifier>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenIdentifier {
    #[serde(default)]
    pub address: Option<String>,
    #[serde(default)]
    pub chain: Option<String>,
    #[serde(rename = "pricingID", default)]
    pub pricing_id: Option<String>,
}

/// A Solana DEX pool entry in search results.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolanaPool {
    #[serde(rename = "poolAddress", default)]
    pub pool_address: Option<String>,
    #[serde(rename = "tokenAddress", default)]
    pub token_address: Option<String>,
    #[serde(rename = "tokenName", default)]
    pub token_name: Option<String>,
    #[serde(rename = "tokenSymbol", default)]
    pub token_symbol: Option<String>,
    #[serde(rename = "priceUsd", default)]
    pub price_usd: Option<f64>,
    #[serde(rename = "liquidityUsd", default)]
    pub liquidity_usd: Option<f64>,
}

// ─── /intelligence/address_enriched/{address}/all ────────────────────────────
//
// The endpoint returns a flat object keyed by chain slug
// (`ethereum`, `bsc`, `polygon`, …). Each value is a per-chain enrichment
// record. We represent it as a HashMap so callers can iterate or look up
// a specific chain without having an enum exhaustively listing every chain
// Arkham might add tomorrow.

/// Per-chain enriched record returned for a single address.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainAddressInfo {
    #[serde(default)]
    pub address: Option<String>,
    #[serde(default)]
    pub chain: Option<String>,
    #[serde(rename = "isUserAddress", default)]
    pub is_user_address: bool,
    #[serde(default)]
    pub contract: bool,
    #[serde(rename = "populatedTags", default)]
    pub populated_tags: Vec<AddressTag>,
}

/// One Arkham label/tag attached to an address on a specific chain.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddressTag {
    #[serde(default)]
    pub id: Option<String>,
    #[serde(default)]
    pub label: Option<String>,
    #[serde(default)]
    pub rank: i64,
    #[serde(default)]
    pub chain: String,
    #[serde(rename = "excludeEntities", default)]
    pub exclude_entities: bool,
    #[serde(rename = "disablePage", default)]
    pub disable_page: bool,
}

/// Wrapper around the chain-keyed map. New-typed so callers can hang
/// helper methods on it without orphan-rule issues.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AddressEnriched {
    pub chains: HashMap<String, ChainAddressInfo>,
}

impl AddressEnriched {
    /// Chains with non-empty data on this address — i.e. anywhere we found a
    /// label, contract flag, or just a record. Useful for ranking which
    /// chain to drill into first when an address shows up cross-chain.
    pub fn known_chains(&self) -> Vec<&str> {
        self.chains.keys().map(String::as_str).collect()
    }

    /// Convenience: every tag across every chain, flattened.
    pub fn all_tags(&self) -> impl Iterator<Item = &AddressTag> {
        self.chains.values().flat_map(|c| c.populated_tags.iter())
    }
}

// ─── /transfers ─────────────────────────────────────────────────────────────

/// Page of transfers returned by `/transfers`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TransfersPage {
    /// Total matching transfers on the server side (NOT the page size). Used
    /// for paging via `offset` + `limit`.
    #[serde(default)]
    pub count: u64,
    #[serde(default)]
    pub transfers: Vec<Transfer>,
}

/// A single token transfer. Identifying fields (`id`, `transaction_hash`,
/// `chain`) are `Option` because Arkham doesn't formally guarantee they
/// stay populated across all transfer types — better to surface a `None`
/// than panic during deserialization.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Transfer {
    #[serde(default)]
    pub id: Option<String>,
    #[serde(rename = "transactionHash", default)]
    pub transaction_hash: Option<String>,
    #[serde(default)]
    pub chain: Option<String>,

    #[serde(rename = "fromAddress", default)]
    pub from_address: Option<TransferParty>,
    #[serde(rename = "toAddress", default)]
    pub to_address: Option<TransferParty>,

    #[serde(rename = "tokenAddress", default)]
    pub token_address: Option<String>,
    #[serde(rename = "tokenName", default)]
    pub token_name: Option<String>,
    #[serde(rename = "tokenSymbol", default)]
    pub token_symbol: Option<String>,
    #[serde(rename = "tokenDecimals", default)]
    pub token_decimals: Option<i64>,
    #[serde(rename = "tokenId", default)]
    pub token_id: Option<String>,

    /// Token amount in its native unit (already divided by 10^decimals).
    #[serde(rename = "unitValue", default)]
    pub unit_value: Option<f64>,
    /// USD value at transfer time.
    #[serde(rename = "historicalUSD", default)]
    pub historical_usd: Option<f64>,

    #[serde(rename = "blockNumber", default)]
    pub block_number: Option<u64>,
    #[serde(rename = "blockTimestamp", default)]
    pub block_timestamp: Option<String>,
    #[serde(rename = "blockHash", default)]
    pub block_hash: Option<String>,

    #[serde(rename = "type", default)]
    pub r#type: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransferParty {
    #[serde(default)]
    pub address: Option<String>,
    #[serde(default)]
    pub chain: Option<String>,
    #[serde(rename = "isUserAddress", default)]
    pub is_user_address: bool,
    #[serde(default)]
    pub contract: bool,
}

/// Filter parameters for the `/transfers` endpoint. Only the fields callers
/// actually populate get serialized into the query string. All optional.
#[derive(Debug, Clone, Default)]
pub struct TransfersQuery<'a> {
    /// `base=` repeats per address; `["user"]` returns flow involving the
    /// caller's saved addresses (requires identity).
    pub base: Option<&'a [&'a str]>,
    /// `flow=in` / `out` / `all`. Server defaults if omitted.
    pub flow: Option<&'a str>,
    /// Lower-bound USD value (inclusive). Common: `"1"` to filter dust.
    pub usd_gte: Option<&'a str>,
    /// `time` / `usd` / etc. — see OpenAPI `TransferSortKey`.
    pub sort_key: Option<&'a str>,
    /// `asc` / `desc`.
    pub sort_dir: Option<&'a str>,
    pub limit: Option<u32>,
    pub offset: Option<u32>,
}