hyper-agent-core 0.1.0

Core domain logic for hyper-agent: pipeline, executor, signals, positions
Documentation
//! Credential resolution with a priority chain: env vars -> TOML config.
//!
//! This replaces the old `load_api_key_from_keyring()` approach with a
//! deterministic, testable resolver that does not depend on OS keyring.
//!
//! # Resolution order
//!
//! | Credential       | 1st (highest priority)       | 2nd                     | 3rd (TOML fallback)                    |
//! |------------------|------------------------------|-------------------------|----------------------------------------|
//! | Anthropic API    | `ANTHROPIC_OAUTH_TOKEN`      | `ANTHROPIC_API_KEY`     | `credentials.anthropic_api_key`        |
//! | Hyperliquid key  | `HYPERLIQUID_PRIVATE_KEY`    | —                       | `credentials.hyperliquid_private_key`  |

use crate::config::CredentialsSection;

/// Resolves credentials from environment variables and TOML config.
///
/// The resolver checks env vars first, then falls back to the TOML
/// `[credentials]` section. Empty strings are treated as absent.
pub struct CredentialResolver {
    config: CredentialsSection,
}

impl CredentialResolver {
    /// Create a new resolver backed by the given TOML credentials section.
    pub fn new(config: CredentialsSection) -> Self {
        Self { config }
    }

    /// Resolve the Anthropic API key.
    ///
    /// Priority: `ANTHROPIC_OAUTH_TOKEN` > `ANTHROPIC_API_KEY` > TOML field.
    pub fn anthropic_key(&self) -> Option<String> {
        std::env::var("ANTHROPIC_OAUTH_TOKEN")
            .ok()
            .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
            .or_else(|| self.config.anthropic_api_key.clone())
            .filter(|s| !s.is_empty())
    }

    /// Resolve the Hyperliquid private key.
    ///
    /// Priority: `HYPERLIQUID_PRIVATE_KEY` env var > TOML field.
    pub fn hyperliquid_key(&self) -> Option<String> {
        std::env::var("HYPERLIQUID_PRIVATE_KEY")
            .ok()
            .or_else(|| self.config.hyperliquid_private_key.clone())
            .filter(|s| !s.is_empty())
    }

    /// Derive the Ethereum address from the Hyperliquid private key.
    ///
    /// Returns `None` if no key is configured. Returns `Some(Err(..))` if
    /// the key is present but cannot be parsed.
    pub fn hyperliquid_address(&self) -> Option<Result<String, String>> {
        self.hyperliquid_key().map(|key_hex| {
            let stripped = key_hex
                .strip_prefix("0x")
                .or_else(|| key_hex.strip_prefix("0X"))
                .unwrap_or(&key_hex);
            let key_bytes =
                hex::decode(stripped).map_err(|e| format!("invalid hex in private key: {e}"))?;
            let signing_key = k256::ecdsa::SigningKey::from_bytes(key_bytes.as_slice().into())
                .map_err(|e| format!("invalid secp256k1 key: {e}"))?;
            let verifying_key = signing_key.verifying_key();
            let point = verifying_key.to_encoded_point(false);
            let pubkey_bytes = &point.as_bytes()[1..];
            use sha3::{Digest, Keccak256};
            let hash = Keccak256::digest(pubkey_bytes);
            Ok(format!("0x{}", hex::encode(&hash[12..])))
        })
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use std::env;
    use std::sync::Mutex;

    /// Global mutex to serialize tests that manipulate environment variables.
    static ENV_LOCK: Mutex<()> = Mutex::new(());

    fn clear_cred_env_vars() {
        env::remove_var("ANTHROPIC_OAUTH_TOKEN");
        env::remove_var("ANTHROPIC_API_KEY");
        env::remove_var("HYPERLIQUID_PRIVATE_KEY");
    }

    fn empty_config() -> CredentialsSection {
        CredentialsSection {
            anthropic_api_key: None,
            hyperliquid_private_key: None,
        }
    }

    #[test]
    fn test_anthropic_key_from_oauth_token_env() {
        let _lock = ENV_LOCK.lock().unwrap();
        clear_cred_env_vars();
        env::set_var("ANTHROPIC_OAUTH_TOKEN", "oauth-tok");
        env::set_var("ANTHROPIC_API_KEY", "api-key");

        let resolver = CredentialResolver::new(CredentialsSection {
            anthropic_api_key: Some("toml-key".to_string()),
            hyperliquid_private_key: None,
        });

        // OAUTH_TOKEN wins over API_KEY and TOML
        assert_eq!(resolver.anthropic_key().as_deref(), Some("oauth-tok"));
        clear_cred_env_vars();
    }

    #[test]
    fn test_anthropic_key_from_api_key_env() {
        let _lock = ENV_LOCK.lock().unwrap();
        clear_cred_env_vars();
        env::set_var("ANTHROPIC_API_KEY", "api-key");

        let resolver = CredentialResolver::new(CredentialsSection {
            anthropic_api_key: Some("toml-key".to_string()),
            hyperliquid_private_key: None,
        });

        assert_eq!(resolver.anthropic_key().as_deref(), Some("api-key"));
        clear_cred_env_vars();
    }

    #[test]
    fn test_anthropic_key_from_toml() {
        let _lock = ENV_LOCK.lock().unwrap();
        clear_cred_env_vars();

        let resolver = CredentialResolver::new(CredentialsSection {
            anthropic_api_key: Some("toml-key".to_string()),
            hyperliquid_private_key: None,
        });

        assert_eq!(resolver.anthropic_key().as_deref(), Some("toml-key"));
        clear_cred_env_vars();
    }

    #[test]
    fn test_anthropic_key_none_when_all_absent() {
        let _lock = ENV_LOCK.lock().unwrap();
        clear_cred_env_vars();

        let resolver = CredentialResolver::new(empty_config());
        assert!(resolver.anthropic_key().is_none());
        clear_cred_env_vars();
    }

    #[test]
    fn test_anthropic_key_empty_string_treated_as_absent() {
        let _lock = ENV_LOCK.lock().unwrap();
        clear_cred_env_vars();
        env::set_var("ANTHROPIC_API_KEY", "");

        let resolver = CredentialResolver::new(CredentialsSection {
            anthropic_api_key: Some(String::new()),
            hyperliquid_private_key: None,
        });

        assert!(resolver.anthropic_key().is_none());
        clear_cred_env_vars();
    }

    #[test]
    fn test_hyperliquid_key_from_env() {
        let _lock = ENV_LOCK.lock().unwrap();
        clear_cred_env_vars();
        env::set_var("HYPERLIQUID_PRIVATE_KEY", "0xENV");

        let resolver = CredentialResolver::new(CredentialsSection {
            anthropic_api_key: None,
            hyperliquid_private_key: Some("0xTOML".to_string()),
        });

        assert_eq!(resolver.hyperliquid_key().as_deref(), Some("0xENV"));
        clear_cred_env_vars();
    }

    #[test]
    fn test_hyperliquid_key_from_toml() {
        let _lock = ENV_LOCK.lock().unwrap();
        clear_cred_env_vars();

        let resolver = CredentialResolver::new(CredentialsSection {
            anthropic_api_key: None,
            hyperliquid_private_key: Some("0xTOML".to_string()),
        });

        assert_eq!(resolver.hyperliquid_key().as_deref(), Some("0xTOML"));
        clear_cred_env_vars();
    }

    #[test]
    fn test_hyperliquid_key_none_when_absent() {
        let _lock = ENV_LOCK.lock().unwrap();
        clear_cred_env_vars();

        let resolver = CredentialResolver::new(empty_config());
        assert!(resolver.hyperliquid_key().is_none());
        clear_cred_env_vars();
    }
}