rustrade_execution/client/hyperliquid/
config.rs1use 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#[derive(Debug, Clone)]
20pub struct HyperliquidConfig {
21 pub wallet: LocalWallet,
23 pub network: Network,
25 pub dexes: Vec<SmolStr>,
38}
39
40impl HyperliquidConfig {
41 pub fn new(wallet: LocalWallet, network: Network) -> Self {
44 Self {
45 wallet,
46 network,
47 dexes: Vec::new(),
48 }
49 }
50
51 #[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 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 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 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 pub fn wallet_address_hex(&self) -> String {
141 format!("{:#x}", self.wallet.address())
142 }
143
144 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#[derive(Debug, Clone, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct HyperliquidConfigFile {
163 #[serde(default = "default_network")]
168 pub network: Network,
169
170 #[serde(default)]
172 pub dexes: Vec<SmolStr>,
173}
174
175fn default_network() -> Network {
181 Network::Testnet
182}
183
184#[derive(Debug, PartialEq, thiserror::Error)]
186pub enum HyperliquidConfigError {
187 #[error("HYPERLIQUID_PRIVATE_KEY environment variable not set")]
188 MissingPrivateKey,
189
190 #[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)] mod 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 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 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}