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
//! Pure JSON → typed conversions. No I/O.

use serde_json::Value;
use tail_fin_common::TailFinError;

use crate::types::{AddressEnriched, SearchResults, TransfersPage};

/// Parse a `GET /intelligence/search` response into [`SearchResults`].
/// Unknown / extra fields in the JSON are silently dropped — keeps the
/// adapter robust to upstream additions.
pub fn parse_search_results(v: &Value) -> Result<SearchResults, TailFinError> {
    serde_json::from_value::<SearchResults>(v.clone())
        .map_err(|e| TailFinError::Api(format!("failed to parse Arkham search results: {e}")))
}

/// Parse a `GET /intelligence/address_enriched/{address}/all` response.
/// The server returns a flat object keyed by chain slug; we wrap it in
/// [`AddressEnriched`] so callers can iterate or look up specific chains.
pub fn parse_address_enriched(v: &Value) -> Result<AddressEnriched, TailFinError> {
    serde_json::from_value::<AddressEnriched>(v.clone())
        .map_err(|e| TailFinError::Api(format!("failed to parse Arkham address_enriched: {e}")))
}

/// Parse a `GET /transfers` response into [`TransfersPage`].
pub fn parse_transfers(v: &Value) -> Result<TransfersPage, TailFinError> {
    serde_json::from_value::<TransfersPage>(v.clone())
        .map_err(|e| TailFinError::Api(format!("failed to parse Arkham transfers: {e}")))
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    /// Trimmed-down replay of a real `?query=binance` response from live API.
    fn binance_search_fixture() -> Value {
        json!({
            "arkhamEntities": [
                {"name": "Binance", "note": "", "id": "binance", "type": "cex", "service": true, "addresses": null, "twitter": "binance"},
                {"name": "Binance Pool", "note": "", "id": "binance-pool", "type": "miner-validator", "service": false, "addresses": null, "twitter": "binance"}
            ],
            "tokens": [
                {
                    "name": "Binance Coin",
                    "symbol": "bnb",
                    "price": 612.34,
                    "price24hAgo": 605.10,
                    "identifier": {"address": "0xB8c77482e45F1F44dE1745F52C74426C631bDD52", "chain": "ethereum", "pricingID": "binancecoin"}
                }
            ],
            "pools": [],
            "tags": [],
            "twitter": [],
            "services": []
        })
    }

    #[test]
    fn parse_extracts_entity_names_and_ids() {
        let r = parse_search_results(&binance_search_fixture()).expect("parse OK");
        let names: Vec<&str> = r
            .arkham_entities
            .iter()
            .filter_map(|e| e.name.as_deref())
            .collect();
        assert_eq!(names, vec!["Binance", "Binance Pool"]);
        assert_eq!(r.arkham_entities[0].id.as_deref(), Some("binance"));
        assert_eq!(r.arkham_entities[0].r#type.as_deref(), Some("cex"));
        assert_eq!(r.arkham_entities[0].service, Some(true));
    }

    #[test]
    fn parse_extracts_token_with_renamed_fields() {
        let r = parse_search_results(&binance_search_fixture()).expect("parse OK");
        assert_eq!(r.tokens.len(), 1);
        let t = &r.tokens[0];
        assert_eq!(t.name.as_deref(), Some("Binance Coin"));
        assert_eq!(t.symbol.as_deref(), Some("bnb"));
        assert_eq!(t.price, Some(612.34));
        // price24hAgo (camelCase) → price_24h_ago (snake_case)
        assert_eq!(t.price_24h_ago, Some(605.10));
        let ident = t.identifier.as_ref().expect("identifier");
        assert_eq!(ident.chain.as_deref(), Some("ethereum"));
        // pricingID (capital ID) → pricing_id
        assert_eq!(ident.pricing_id.as_deref(), Some("binancecoin"));
    }

    #[test]
    fn parse_handles_empty_response() {
        let r = parse_search_results(&json!({})).expect("parse OK");
        assert!(r.arkham_entities.is_empty());
        assert!(r.tokens.is_empty());
        assert!(r.pools.is_empty());
    }

    #[test]
    fn parse_ignores_unknown_top_level_fields() {
        // Server adds a new field — adapter should not break.
        let v = json!({"arkhamEntities": [], "newFutureField": {"whatever": 42}});
        parse_search_results(&v).expect("parse OK despite unknown field");
    }

    #[test]
    fn parse_errors_on_wrong_type() {
        // arkhamEntities should be an array; passing an object must error.
        let v = json!({"arkhamEntities": {"not": "an array"}});
        assert!(parse_search_results(&v).is_err());
    }

    // ─── address_enriched ──────────────────────────────────────────────────

    /// Real shape: per-chain object, each with optional populatedTags.
    fn enriched_fixture() -> Value {
        json!({
            "bsc": {
                "address": "0x971435Fc38eeD5E0aAFF0dd717d0d16a02a4110e",
                "chain": "bsc",
                "isUserAddress": false,
                "contract": true,
                "populatedTags": [
                    {"id": "high-transacting", "label": "High Transacting", "rank": 160, "excludeEntities": false, "chain": "bsc", "disablePage": false}
                ]
            },
            "ethereum": {
                "address": "0x971435Fc38eeD5E0aAFF0dd717d0d16a02a4110e",
                "chain": "ethereum",
                "isUserAddress": false,
                "contract": false
            }
        })
    }

    #[test]
    fn parse_address_enriched_indexes_by_chain() {
        let r = parse_address_enriched(&enriched_fixture()).expect("parse OK");
        let mut chains: Vec<&str> = r.known_chains();
        chains.sort();
        assert_eq!(chains, vec!["bsc", "ethereum"]);
    }

    #[test]
    fn parse_address_enriched_extracts_tags() {
        let r = parse_address_enriched(&enriched_fixture()).expect("parse OK");
        let bsc = r.chains.get("bsc").expect("bsc");
        assert!(bsc.contract);
        assert_eq!(bsc.populated_tags.len(), 1);
        assert_eq!(
            bsc.populated_tags[0].label.as_deref(),
            Some("High Transacting")
        );
        assert_eq!(bsc.populated_tags[0].rank, 160);

        // ethereum has no populated_tags in fixture; #[serde(default)] gives empty Vec
        let eth = r.chains.get("ethereum").expect("ethereum");
        assert!(eth.populated_tags.is_empty());

        // all_tags() flattens across chains
        let all: Vec<&str> = r.all_tags().filter_map(|t| t.label.as_deref()).collect();
        assert_eq!(all, vec!["High Transacting"]);
    }

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

    fn transfers_fixture() -> Value {
        json!({
            "count": 10000,
            "transfers": [
                {
                    "id": "0xabc_1",
                    "transactionHash": "0xabc",
                    "chain": "bsc",
                    "fromAddress": {"address": "0x111", "chain": "bsc", "isUserAddress": false, "contract": true},
                    "toAddress":   {"address": "0x222", "chain": "bsc", "isUserAddress": false, "contract": false},
                    "tokenAddress": "0x8d0d000ee44948fc98c9b98a4fa4921476f08b0d",
                    "tokenName": "World Liberty Financial USD",
                    "tokenSymbol": "USD1",
                    "tokenDecimals": 18,
                    "tokenId": "usd1-wlfi",
                    "unitValue": 38530.95,
                    "historicalUSD": 38530.95,
                    "blockNumber": 70789391,
                    "blockTimestamp": "2025-12-07T07:48:56Z",
                    "blockHash": "0x62c5",
                    "type": ""
                }
            ]
        })
    }

    #[test]
    fn parse_transfers_extracts_count_and_items() {
        let p = parse_transfers(&transfers_fixture()).expect("parse OK");
        assert_eq!(p.count, 10000);
        assert_eq!(p.transfers.len(), 1);
    }

    #[test]
    fn parse_transfers_renames_camelcase_fields() {
        let p = parse_transfers(&transfers_fixture()).expect("parse OK");
        let t = &p.transfers[0];
        assert_eq!(t.transaction_hash.as_deref(), Some("0xabc"));
        assert_eq!(t.token_symbol.as_deref(), Some("USD1"));
        // historicalUSD (uppercase USD) → historical_usd
        assert_eq!(t.historical_usd, Some(38530.95));
        assert_eq!(t.unit_value, Some(38530.95));
        assert_eq!(t.token_decimals, Some(18));
        let from = t.from_address.as_ref().expect("fromAddress");
        let to = t.to_address.as_ref().expect("toAddress");
        assert_eq!(from.address.as_deref(), Some("0x111"));
        assert!(from.contract);
        assert!(!to.contract);
    }

    #[test]
    fn parse_transfers_handles_empty_page() {
        let v = json!({"count": 0, "transfers": []});
        let p = parse_transfers(&v).expect("parse OK");
        assert_eq!(p.count, 0);
        assert!(p.transfers.is_empty());
    }
}