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
//! Request signing for `api.arkm.com`.
//!
//! Algorithm (reverse-engineered from `_next/static/chunks/0695b61d40170c4a.js`,
//! 2026-04-30 build `daz5_idGWzSdzI6qv-LNo`):
//!
//! ```text
//! inner    = sha256(path + ":" + timestamp + ":" + KEY)
//! payload  = sha256(KEY + ":" + inner)
//! ```
//!
//! Where:
//! - `path` is the URL pathname (no query string)
//! - `timestamp` is unix epoch seconds (decimal string)
//! - `KEY` is `ARKM_CLIENT_KEY`, a static value baked into the SPA bundle
//!
//! Server replay window is < 18 min, so callers must use a fresh timestamp
//! per request. Method-agnostic; body is NOT signed.

/// Static signing key embedded in the Arkham SPA's JS bundle as
/// `NEXT_PUBLIC_WEBAPP_CLIENT_KEY`. Same value for every user; not a secret.
/// Re-extract via:
/// `grep -ohE 'NEXT_PUBLIC_WEBAPP_CLIENT_KEY:"[^"]+"' _next/static/chunks/*.js`
pub const ARKM_CLIENT_KEY: &str = "gh67j345kl6hj5k432";

/// Compute the `X-Payload` header value for a request to `api.arkm.com`.
///
/// `path` must be the URL pathname only (no query). `timestamp` is the
/// unix epoch in seconds, formatted as a decimal string (matching
/// `Math.floor(Date.now()/1000).toString()` from the SPA).
#[must_use]
pub fn sign_payload(path: &str, timestamp: &str) -> String {
    sign_payload_with_key(path, timestamp, ARKM_CLIENT_KEY)
}

/// Same as [`sign_payload`] but accepts an explicit key — for the day
/// Arkham rotates `NEXT_PUBLIC_WEBAPP_CLIENT_KEY` and tests need to pin
/// the historical value.
#[must_use]
pub fn sign_payload_with_key(path: &str, timestamp: &str, key: &str) -> String {
    use sha2::{Digest, Sha256};
    let inner = {
        let mut h = Sha256::new();
        h.update(path.as_bytes());
        h.update(b":");
        h.update(timestamp.as_bytes());
        h.update(b":");
        h.update(key.as_bytes());
        hex::encode(h.finalize())
    };
    let mut h = Sha256::new();
    h.update(key.as_bytes());
    h.update(b":");
    h.update(inner.as_bytes());
    hex::encode(h.finalize())
}

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

    /// Five (path, timestamp, expected x-payload) tuples lifted directly from
    /// the live HAR captured 2026-04-30 02:02:45 UTC. If the algorithm or
    /// key ever change upstream, all five fail at once.
    const HAR_FIXTURES: &[(&str, &str, &str)] = &[
        (
            "/user",
            "1777514565",
            "e40dd6181c5901ae5acc014ff893808877118520c33927645b51b438ff1e700e",
        ),
        (
            "/marketdata/geckoterminal/get_dex_pool_data",
            "1777514565",
            "fdb638263c4da2b605639a1d7b585842a5bbd00d57d477ec1e5372686473da12",
        ),
        (
            "/user/entities//is_not_viewable",
            "1777514565",
            "6a677376b5d508076056e4156d9e3ee40f8da62f40c463d13d2c832542f59032",
        ),
        (
            "/loans/address/0x971435fc38eed5e0aaff0dd717d0d16a02a4110e",
            "1777514566",
            "a168022e4def0b8ef68e801e30006c56ca0a7a35e9dfda2d8ebfd2615cc15af4",
        ),
        (
            "/transfers",
            "1777514576",
            "4b1a20d87aa128f631ee1bf94f388c145f784b443f1f5e9ff802c14b50568a83",
        ),
    ];

    #[test]
    fn sign_payload_matches_live_har_fixtures() {
        for (path, ts, expected) in HAR_FIXTURES {
            let got = sign_payload(path, ts);
            assert_eq!(
                &got, expected,
                "signature mismatch for path={path:?} ts={ts}: got {got}, expected {expected}"
            );
        }
    }

    #[test]
    fn sign_payload_returns_64_hex_chars() {
        let p = sign_payload("/anything", "1234567890");
        assert_eq!(p.len(), 64);
        assert!(p.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn sign_payload_changes_with_timestamp() {
        let a = sign_payload("/user", "1700000000");
        let b = sign_payload("/user", "1700000001");
        assert_ne!(a, b);
    }
}