Skip to main content

rustrade_execution/client/hyperliquid/
config.rs

1//! Configuration for the Hyperliquid execution client.
2
3use ethers::signers::{LocalWallet, Signer};
4use serde::{Deserialize, Serialize};
5
6/// Configuration for the Hyperliquid execution client.
7///
8/// # Example
9///
10/// ```ignore
11/// use rustrade_execution::client::hyperliquid::config::HyperliquidConfig;
12/// use std::env;
13///
14/// let config = HyperliquidConfig::from_env().expect("failed to load Hyperliquid config from env");
15/// ```
16#[derive(Debug, Clone)]
17pub struct HyperliquidConfig {
18    /// The wallet containing the private key for signing (ethers LocalWallet).
19    pub wallet: LocalWallet,
20    /// Whether to use testnet (true) or mainnet (false).
21    pub testnet: bool,
22}
23
24impl HyperliquidConfig {
25    /// Create a new config with the given wallet and network selection (`testnet = true` ⇒ testnet,
26    /// `false` ⇒ mainnet with **real funds**).
27    pub fn new(wallet: LocalWallet, testnet: bool) -> Self {
28        Self { wallet, testnet }
29    }
30
31    /// Build a config from environment variables.
32    ///
33    /// Reads:
34    /// - `HYPERLIQUID_PRIVATE_KEY` (required) — hex-encoded private key (with or without `0x` prefix).
35    /// - `HYPERLIQUID_TESTNET` (optional) — `"true"`/`"false"` (case-insensitive). **Absent ⇒ the
36    ///   safe testnet environment.** Set `HYPERLIQUID_TESTNET=false` to target mainnet (real funds).
37    ///
38    /// # Errors
39    ///
40    /// Returns [`HyperliquidConfigError`] (never panics):
41    /// - `HYPERLIQUID_PRIVATE_KEY` unset ([`MissingPrivateKey`](HyperliquidConfigError::MissingPrivateKey)),
42    ///   non-UTF-8 ([`InvalidPrivateKeyVar`](HyperliquidConfigError::InvalidPrivateKeyVar)), or not a valid key
43    ///   ([`InvalidPrivateKey`](HyperliquidConfigError::InvalidPrivateKey));
44    /// - `HYPERLIQUID_TESTNET` is neither `true` nor `false`, or holds non-UTF-8
45    ///   ([`InvalidTestnet`](HyperliquidConfigError::InvalidTestnet)).
46    pub fn from_env() -> Result<Self, HyperliquidConfigError> {
47        let private_key = match std::env::var("HYPERLIQUID_PRIVATE_KEY") {
48            Ok(value) => value,
49            Err(std::env::VarError::NotPresent) => {
50                return Err(HyperliquidConfigError::MissingPrivateKey);
51            }
52            Err(std::env::VarError::NotUnicode(_)) => {
53                return Err(HyperliquidConfigError::InvalidPrivateKeyVar);
54            }
55        };
56
57        let testnet = match std::env::var("HYPERLIQUID_TESTNET") {
58            Ok(value) => crate::parse_env_bool(&value)
59                .ok_or(HyperliquidConfigError::InvalidTestnet(value))?,
60            Err(std::env::VarError::NotPresent) => true,
61            // The toggle value is not secret, so echo it (lossily) like the parse-failure arm above —
62            // an actionable "got X" beats a hardcoded sentinel.
63            Err(std::env::VarError::NotUnicode(value)) => {
64                return Err(HyperliquidConfigError::InvalidTestnet(
65                    value.to_string_lossy().into_owned(),
66                ));
67            }
68        };
69
70        Self::from_private_key(&private_key, testnet)
71    }
72
73    /// Create a config from a hex-encoded private key string.
74    ///
75    /// The private key can have an optional "0x" prefix.
76    pub fn from_private_key(
77        private_key: &str,
78        testnet: bool,
79    ) -> Result<Self, HyperliquidConfigError> {
80        let key = private_key.strip_prefix("0x").unwrap_or(private_key);
81
82        let wallet: LocalWallet = key
83            .parse()
84            .map_err(|e| HyperliquidConfigError::InvalidPrivateKey(format!("{e}")))?;
85
86        Ok(Self { wallet, testnet })
87    }
88
89    /// Returns the wallet address as a hex string (0x-prefixed).
90    pub fn wallet_address_hex(&self) -> String {
91        format!("{:#x}", self.wallet.address())
92    }
93}
94
95/// Serializable version of HyperliquidConfig for config files.
96///
97/// Does NOT include the private key for security reasons.
98/// Use [`HyperliquidConfig::from_env`] to load credentials.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct HyperliquidConfigFile {
101    /// Whether to use testnet (true) or mainnet (false).
102    ///
103    /// An absent `testnet` field defaults to the **safe** testnet environment (`true`), matching
104    /// [`HyperliquidConfig::from_env`] and the Alpaca/Binance config files.
105    #[serde(default = "default_testnet")]
106    pub testnet: bool,
107}
108
109/// Serde default for [`HyperliquidConfigFile::testnet`]: an absent `testnet` field deserializes to
110/// the **safe** testnet environment (`true`).
111///
112/// `#[serde(default = "…")]` requires a named function (it cannot take a literal), so this exists
113/// purely to supply that default to the derive.
114fn default_testnet() -> bool {
115    true
116}
117
118/// Errors that can occur when creating a HyperliquidConfig.
119#[derive(Debug, PartialEq, thiserror::Error)]
120pub enum HyperliquidConfigError {
121    #[error("HYPERLIQUID_PRIVATE_KEY environment variable not set")]
122    MissingPrivateKey,
123
124    // No payload: the raw value is private-key material, so it must never be echoed into an error
125    // message or log. The `Var` suffix distinguishes "the env var is non-UTF-8" from
126    // `InvalidPrivateKey` below ("the var is readable but not a valid key").
127    #[error("HYPERLIQUID_PRIVATE_KEY environment variable is not valid UTF-8")]
128    InvalidPrivateKeyVar,
129
130    #[error("Invalid private key: {0}")]
131    InvalidPrivateKey(String),
132
133    #[error("HYPERLIQUID_TESTNET must be true or false, got {0}")]
134    InvalidTestnet(String),
135}
136
137#[cfg(test)]
138#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
139mod tests {
140    use super::*;
141
142    #[test]
143    fn test_from_private_key_with_prefix() {
144        let key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
145        let config = HyperliquidConfig::from_private_key(key, false).unwrap();
146        assert!(!config.testnet);
147        assert!(config.wallet_address_hex().starts_with("0x"));
148    }
149
150    #[test]
151    fn test_from_private_key_without_prefix() {
152        let key = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
153        let config = HyperliquidConfig::from_private_key(key, true).unwrap();
154        assert!(config.testnet);
155    }
156
157    #[test]
158    fn test_invalid_private_key() {
159        let result = HyperliquidConfig::from_private_key("invalid", false);
160        assert!(result.is_err());
161    }
162
163    // A valid secp256k1 key (Anvil/Hardhat account #0) for `from_env` tests.
164    const TEST_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
165
166    #[test]
167    #[serial_test::serial]
168    fn test_from_env_defaults_to_testnet() {
169        temp_env::with_vars(
170            [
171                ("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
172                ("HYPERLIQUID_TESTNET", None),
173            ],
174            || {
175                let cfg = HyperliquidConfig::from_env().unwrap();
176                assert!(
177                    cfg.testnet,
178                    "absent toggle must default to the safe testnet"
179                );
180            },
181        );
182    }
183
184    #[test]
185    #[serial_test::serial]
186    fn test_from_env_accepts_explicit_mainnet() {
187        temp_env::with_vars(
188            [
189                ("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
190                ("HYPERLIQUID_TESTNET", Some("false")),
191            ],
192            || {
193                let cfg = HyperliquidConfig::from_env().unwrap();
194                assert!(!cfg.testnet);
195            },
196        );
197    }
198
199    #[test]
200    #[serial_test::serial]
201    fn test_from_env_rejects_invalid_testnet() {
202        temp_env::with_vars(
203            [
204                ("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
205                ("HYPERLIQUID_TESTNET", Some("maybe")),
206            ],
207            || {
208                let err = HyperliquidConfig::from_env().unwrap_err();
209                assert!(
210                    matches!(err, HyperliquidConfigError::InvalidTestnet(value) if value == "maybe")
211                );
212            },
213        );
214    }
215
216    #[test]
217    #[serial_test::serial]
218    fn test_from_env_drops_numeric_one_special_case() {
219        // "1" was previously coerced to testnet; the shared env-bool policy is true/false-only,
220        // so it must now be rejected rather than silently accepted.
221        temp_env::with_vars(
222            [
223                ("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
224                ("HYPERLIQUID_TESTNET", Some("1")),
225            ],
226            || {
227                let err = HyperliquidConfig::from_env().unwrap_err();
228                assert!(
229                    matches!(err, HyperliquidConfigError::InvalidTestnet(value) if value == "1")
230                );
231            },
232        );
233    }
234
235    #[test]
236    #[serial_test::serial]
237    fn test_from_env_requires_private_key() {
238        temp_env::with_vars(
239            [
240                ("HYPERLIQUID_PRIVATE_KEY", None),
241                ("HYPERLIQUID_TESTNET", Some("true")),
242            ],
243            || {
244                let err = HyperliquidConfig::from_env().unwrap_err();
245                assert!(matches!(err, HyperliquidConfigError::MissingPrivateKey));
246            },
247        );
248    }
249
250    #[test]
251    fn test_config_file_absent_testnet_defaults_to_testnet() {
252        let file: HyperliquidConfigFile = serde_json::from_str("{}").unwrap();
253        assert!(
254            file.testnet,
255            "absent `testnet` field must default to safe testnet"
256        );
257    }
258}