Skip to main content

rustrade_execution/client/hyperliquid/
config.rs

1//! Configuration for the Hyperliquid execution client.
2
3use ethers::signers::{LocalWallet, Signer};
4use hyperliquid_rust_sdk::BaseUrl;
5pub use rustrade_instrument::hyperliquid::Network;
6use serde::{Deserialize, Serialize};
7use smol_str::SmolStr;
8
9/// Configuration for the Hyperliquid execution client.
10///
11/// # Example
12///
13/// ```ignore
14/// use rustrade_execution::client::hyperliquid::config::HyperliquidConfig;
15/// use std::env;
16///
17/// let config = HyperliquidConfig::from_env().expect("failed to load Hyperliquid config from env");
18/// ```
19#[derive(Debug, Clone)]
20pub struct HyperliquidConfig {
21    /// The wallet containing the private key for signing (ethers LocalWallet).
22    pub wallet: LocalWallet,
23    /// The network to trade on. [`Network::Mainnet`] trades **real funds**.
24    pub network: Network,
25    /// The builder-deployed (HIP-3) perpetual DEXs the perpetuals client trades besides
26    /// Hyperliquid's default one, by the name Hyperliquid lists them under (`xyz`, `flx`), exactly
27    /// as spelled there. Empty by default: only the default DEX.
28    ///
29    /// The perpetuals client reads each one's markets when it connects, and refuses to connect
30    /// if Hyperliquid does not list one. A name given twice counts once. See
31    /// [`HyperliquidClient`](super::HyperliquidClient)'s HIP-3 docs. The spot client ignores
32    /// this.
33    ///
34    /// Each DEX adds a `clearinghouseState` (weight 2) and an `openOrders` (weight 20) request to
35    /// every account snapshot, and an `openOrders` to every listing of open orders, against
36    /// Hyperliquid's REST limit of 1,200 a minute per IP address.
37    pub dexes: Vec<SmolStr>,
38}
39
40impl HyperliquidConfig {
41    /// Create a new config with the given wallet on `network` ([`Network::Mainnet`] trades **real
42    /// funds**), trading only Hyperliquid's default perpetual DEX.
43    pub fn new(wallet: LocalWallet, network: Network) -> Self {
44        Self {
45            wallet,
46            network,
47            dexes: Vec::new(),
48        }
49    }
50
51    /// Trade the builder-deployed (HIP-3) perpetual DEXs `dexes` as well, replacing any named
52    /// before. See [`dexes`](Self::dexes).
53    #[must_use]
54    pub fn with_dexes<Dex: Into<SmolStr>>(mut self, dexes: impl IntoIterator<Item = Dex>) -> Self {
55        self.dexes = dexes.into_iter().map(Into::into).collect();
56        self
57    }
58
59    /// Build a config from environment variables.
60    ///
61    /// Reads:
62    /// - `HYPERLIQUID_PRIVATE_KEY` (required) — hex-encoded private key (with or without `0x` prefix).
63    /// - `HYPERLIQUID_TESTNET` (optional) — `"true"`/`"false"` (case-insensitive). **Absent ⇒ the
64    ///   safe testnet environment.** Set `HYPERLIQUID_TESTNET=false` to target mainnet (real funds).
65    /// - `HYPERLIQUID_DEXES` (optional) — the HIP-3 DEXs to trade, comma-separated (`xyz,flx`).
66    ///   Spaces around a name and empty entries are ignored. Absent ⇒ none.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`HyperliquidConfigError`] (never panics):
71    /// - `HYPERLIQUID_PRIVATE_KEY` unset ([`MissingPrivateKey`](HyperliquidConfigError::MissingPrivateKey)),
72    ///   non-UTF-8 ([`InvalidPrivateKeyVar`](HyperliquidConfigError::InvalidPrivateKeyVar)), or not a valid key
73    ///   ([`InvalidPrivateKey`](HyperliquidConfigError::InvalidPrivateKey));
74    /// - `HYPERLIQUID_TESTNET` is neither `true` nor `false`, or holds non-UTF-8
75    ///   ([`InvalidTestnet`](HyperliquidConfigError::InvalidTestnet));
76    /// - `HYPERLIQUID_DEXES` holds non-UTF-8 ([`InvalidDexes`](HyperliquidConfigError::InvalidDexes)).
77    pub fn from_env() -> Result<Self, HyperliquidConfigError> {
78        let private_key = match std::env::var("HYPERLIQUID_PRIVATE_KEY") {
79            Ok(value) => value,
80            Err(std::env::VarError::NotPresent) => {
81                return Err(HyperliquidConfigError::MissingPrivateKey);
82            }
83            Err(std::env::VarError::NotUnicode(_)) => {
84                return Err(HyperliquidConfigError::InvalidPrivateKeyVar);
85            }
86        };
87
88        let network = match std::env::var("HYPERLIQUID_TESTNET") {
89            Ok(value) => match crate::parse_env_bool(&value) {
90                Some(true) => Network::Testnet,
91                Some(false) => Network::Mainnet,
92                None => return Err(HyperliquidConfigError::InvalidTestnet(value)),
93            },
94            Err(std::env::VarError::NotPresent) => Network::Testnet,
95            // The toggle value is not secret, so echo it (lossily) like the parse-failure arm above —
96            // an actionable "got X" beats a hardcoded sentinel.
97            Err(std::env::VarError::NotUnicode(value)) => {
98                return Err(HyperliquidConfigError::InvalidTestnet(
99                    value.to_string_lossy().into_owned(),
100                ));
101            }
102        };
103
104        let dexes = match std::env::var("HYPERLIQUID_DEXES") {
105            Ok(value) => value
106                .split(',')
107                .map(str::trim)
108                .filter(|dex| !dex.is_empty())
109                .map(SmolStr::from)
110                .collect(),
111            Err(std::env::VarError::NotPresent) => Vec::new(),
112            Err(std::env::VarError::NotUnicode(value)) => {
113                return Err(HyperliquidConfigError::InvalidDexes(
114                    value.to_string_lossy().into_owned(),
115                ));
116            }
117        };
118
119        Ok(Self::from_private_key(&private_key, network)?.with_dexes(dexes))
120    }
121
122    /// Create a config from a hex-encoded private key string.
123    ///
124    /// The private key can have an optional "0x" prefix. Trades only Hyperliquid's default
125    /// perpetual DEX; add HIP-3 ones with [`with_dexes`](Self::with_dexes).
126    pub fn from_private_key(
127        private_key: &str,
128        network: Network,
129    ) -> Result<Self, HyperliquidConfigError> {
130        let key = private_key.strip_prefix("0x").unwrap_or(private_key);
131
132        let wallet: LocalWallet = key
133            .parse()
134            .map_err(|e| HyperliquidConfigError::InvalidPrivateKey(format!("{e}")))?;
135
136        Ok(Self::new(wallet, network))
137    }
138
139    /// Returns the wallet address as a hex string (0x-prefixed).
140    pub fn wallet_address_hex(&self) -> String {
141        format!("{:#x}", self.wallet.address())
142    }
143
144    /// The SDK's base URL for [`network`](Self::network).
145    pub(super) fn base_url(&self) -> BaseUrl {
146        match self.network {
147            Network::Mainnet => BaseUrl::Mainnet,
148            Network::Testnet => BaseUrl::Testnet,
149        }
150    }
151}
152
153/// Serializable version of HyperliquidConfig for config files.
154///
155/// Does NOT include the private key for security reasons.
156/// Use [`HyperliquidConfig::from_env`] to load credentials.
157///
158/// An unknown field is an error, so a file written for an earlier version, whose `testnet` field
159/// `network` replaced, fails to load rather than fall back to testnet without a word.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct HyperliquidConfigFile {
163    /// The network to trade on, `"mainnet"` or `"testnet"`.
164    ///
165    /// An absent `network` field defaults to the **safe** testnet, matching
166    /// [`HyperliquidConfig::from_env`] and the Alpaca/Binance config files.
167    #[serde(default = "default_network")]
168    pub network: Network,
169
170    /// The HIP-3 DEXs to trade; see [`HyperliquidConfig::dexes`]. Absent ⇒ none.
171    #[serde(default)]
172    pub dexes: Vec<SmolStr>,
173}
174
175/// Serde default for [`HyperliquidConfigFile::network`]: an absent `network` field deserializes to
176/// the **safe** testnet.
177///
178/// `#[serde(default = "…")]` requires a named function (it cannot take a literal), so this exists
179/// purely to supply that default to the derive.
180fn default_network() -> Network {
181    Network::Testnet
182}
183
184/// Errors that can occur when creating a HyperliquidConfig.
185#[derive(Debug, PartialEq, thiserror::Error)]
186pub enum HyperliquidConfigError {
187    #[error("HYPERLIQUID_PRIVATE_KEY environment variable not set")]
188    MissingPrivateKey,
189
190    // No payload: the raw value is private-key material, so it must never be echoed into an error
191    // message or log. The `Var` suffix distinguishes "the env var is non-UTF-8" from
192    // `InvalidPrivateKey` below ("the var is readable but not a valid key").
193    #[error("HYPERLIQUID_PRIVATE_KEY environment variable is not valid UTF-8")]
194    InvalidPrivateKeyVar,
195
196    #[error("Invalid private key: {0}")]
197    InvalidPrivateKey(String),
198
199    #[error("HYPERLIQUID_TESTNET must be true or false, got {0}")]
200    InvalidTestnet(String),
201
202    #[error("HYPERLIQUID_DEXES environment variable is not valid UTF-8: {0}")]
203    InvalidDexes(String),
204}
205
206#[cfg(test)]
207#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_from_private_key_with_prefix() {
213        let key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
214        let config = HyperliquidConfig::from_private_key(key, Network::Mainnet).unwrap();
215        assert_eq!(config.network, Network::Mainnet);
216        assert!(config.dexes.is_empty());
217        assert!(config.wallet_address_hex().starts_with("0x"));
218    }
219
220    #[test]
221    fn test_from_private_key_without_prefix() {
222        let key = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
223        let config = HyperliquidConfig::from_private_key(key, Network::Testnet).unwrap();
224        assert_eq!(config.network, Network::Testnet);
225    }
226
227    #[test]
228    fn test_invalid_private_key() {
229        let result = HyperliquidConfig::from_private_key("invalid", Network::Mainnet);
230        assert!(result.is_err());
231    }
232
233    // A valid secp256k1 key (Anvil/Hardhat account #0) for `from_env` tests.
234    const TEST_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
235
236    #[test]
237    #[serial_test::serial]
238    fn test_from_env_defaults_to_testnet() {
239        temp_env::with_vars(
240            [
241                ("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
242                ("HYPERLIQUID_TESTNET", None),
243            ],
244            || {
245                let cfg = HyperliquidConfig::from_env().unwrap();
246                assert_eq!(
247                    cfg.network,
248                    Network::Testnet,
249                    "absent toggle must default to the safe testnet"
250                );
251            },
252        );
253    }
254
255    #[test]
256    #[serial_test::serial]
257    fn test_from_env_accepts_explicit_mainnet() {
258        temp_env::with_vars(
259            [
260                ("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
261                ("HYPERLIQUID_TESTNET", Some("false")),
262            ],
263            || {
264                let cfg = HyperliquidConfig::from_env().unwrap();
265                assert_eq!(cfg.network, Network::Mainnet);
266            },
267        );
268    }
269
270    #[test]
271    #[serial_test::serial]
272    fn test_from_env_rejects_invalid_testnet() {
273        temp_env::with_vars(
274            [
275                ("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
276                ("HYPERLIQUID_TESTNET", Some("maybe")),
277            ],
278            || {
279                let err = HyperliquidConfig::from_env().unwrap_err();
280                assert!(
281                    matches!(err, HyperliquidConfigError::InvalidTestnet(value) if value == "maybe")
282                );
283            },
284        );
285    }
286
287    #[test]
288    #[serial_test::serial]
289    fn test_from_env_drops_numeric_one_special_case() {
290        // "1" was previously coerced to testnet; the shared env-bool policy is true/false-only,
291        // so it must now be rejected rather than silently accepted.
292        temp_env::with_vars(
293            [
294                ("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
295                ("HYPERLIQUID_TESTNET", Some("1")),
296            ],
297            || {
298                let err = HyperliquidConfig::from_env().unwrap_err();
299                assert!(
300                    matches!(err, HyperliquidConfigError::InvalidTestnet(value) if value == "1")
301                );
302            },
303        );
304    }
305
306    #[test]
307    #[serial_test::serial]
308    fn test_from_env_requires_private_key() {
309        temp_env::with_vars(
310            [
311                ("HYPERLIQUID_PRIVATE_KEY", None),
312                ("HYPERLIQUID_TESTNET", Some("true")),
313            ],
314            || {
315                let err = HyperliquidConfig::from_env().unwrap_err();
316                assert!(matches!(err, HyperliquidConfigError::MissingPrivateKey));
317            },
318        );
319    }
320
321    #[test]
322    #[serial_test::serial]
323    fn from_env_reads_the_dexes_to_trade() {
324        temp_env::with_vars(
325            [
326                ("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
327                ("HYPERLIQUID_TESTNET", None),
328                ("HYPERLIQUID_DEXES", Some(" xyz, ,flx ,")),
329            ],
330            || {
331                let cfg = HyperliquidConfig::from_env().unwrap();
332                assert_eq!(cfg.dexes, ["xyz", "flx"]);
333            },
334        );
335    }
336
337    #[cfg(unix)]
338    #[test]
339    #[serial_test::serial]
340    fn from_env_rejects_non_utf8_dexes() {
341        use std::os::unix::ffi::OsStringExt;
342        let dexes = std::ffi::OsString::from_vec(vec![b'x', 0xff]);
343        temp_env::with_vars(
344            [
345                (
346                    "HYPERLIQUID_PRIVATE_KEY",
347                    Some(std::ffi::OsString::from(TEST_KEY)),
348                ),
349                ("HYPERLIQUID_DEXES", Some(dexes)),
350            ],
351            || {
352                let err = HyperliquidConfig::from_env().unwrap_err();
353                assert!(matches!(err, HyperliquidConfigError::InvalidDexes(_)));
354            },
355        );
356    }
357
358    #[test]
359    #[serial_test::serial]
360    fn from_env_trades_no_dex_when_none_is_named() {
361        temp_env::with_vars(
362            [
363                ("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
364                ("HYPERLIQUID_DEXES", None),
365            ],
366            || assert!(HyperliquidConfig::from_env().unwrap().dexes.is_empty()),
367        );
368    }
369
370    #[test]
371    fn with_dexes_replaces_the_dexes_named_before() {
372        let config = HyperliquidConfig::from_private_key(TEST_KEY, Network::Testnet)
373            .unwrap()
374            .with_dexes(["xyz"])
375            .with_dexes(["flx", "km"]);
376        assert_eq!(config.dexes, ["flx", "km"]);
377    }
378
379    #[test]
380    fn test_config_file_absent_network_defaults_to_testnet() {
381        let file: HyperliquidConfigFile = serde_json::from_str("{}").unwrap();
382        assert_eq!(
383            file.network,
384            Network::Testnet,
385            "absent `network` field must default to safe testnet"
386        );
387        assert!(file.dexes.is_empty());
388    }
389
390    #[test]
391    fn a_config_file_from_before_network_fails_to_load() {
392        let file = serde_json::from_str::<HyperliquidConfigFile>(r#"{"testnet": false}"#);
393        assert!(file.unwrap_err().to_string().contains("testnet"));
394    }
395
396    #[test]
397    fn a_config_file_names_its_network_and_dexes() {
398        let file: HyperliquidConfigFile =
399            serde_json::from_str(r#"{"network": "mainnet", "dexes": ["xyz", "flx"]}"#).unwrap();
400        assert_eq!(file.network, Network::Mainnet);
401        assert_eq!(file.dexes, ["xyz", "flx"]);
402    }
403}