use ethers::signers::{LocalWallet, Signer};
use hyperliquid_rust_sdk::BaseUrl;
pub use rustrade_instrument::hyperliquid::Network;
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
#[derive(Debug, Clone)]
pub struct HyperliquidConfig {
pub wallet: LocalWallet,
pub network: Network,
pub dexes: Vec<SmolStr>,
}
impl HyperliquidConfig {
pub fn new(wallet: LocalWallet, network: Network) -> Self {
Self {
wallet,
network,
dexes: Vec::new(),
}
}
#[must_use]
pub fn with_dexes<Dex: Into<SmolStr>>(mut self, dexes: impl IntoIterator<Item = Dex>) -> Self {
self.dexes = dexes.into_iter().map(Into::into).collect();
self
}
pub fn from_env() -> Result<Self, HyperliquidConfigError> {
let private_key = match std::env::var("HYPERLIQUID_PRIVATE_KEY") {
Ok(value) => value,
Err(std::env::VarError::NotPresent) => {
return Err(HyperliquidConfigError::MissingPrivateKey);
}
Err(std::env::VarError::NotUnicode(_)) => {
return Err(HyperliquidConfigError::InvalidPrivateKeyVar);
}
};
let network = match std::env::var("HYPERLIQUID_TESTNET") {
Ok(value) => match crate::parse_env_bool(&value) {
Some(true) => Network::Testnet,
Some(false) => Network::Mainnet,
None => return Err(HyperliquidConfigError::InvalidTestnet(value)),
},
Err(std::env::VarError::NotPresent) => Network::Testnet,
Err(std::env::VarError::NotUnicode(value)) => {
return Err(HyperliquidConfigError::InvalidTestnet(
value.to_string_lossy().into_owned(),
));
}
};
let dexes = match std::env::var("HYPERLIQUID_DEXES") {
Ok(value) => value
.split(',')
.map(str::trim)
.filter(|dex| !dex.is_empty())
.map(SmolStr::from)
.collect(),
Err(std::env::VarError::NotPresent) => Vec::new(),
Err(std::env::VarError::NotUnicode(value)) => {
return Err(HyperliquidConfigError::InvalidDexes(
value.to_string_lossy().into_owned(),
));
}
};
Ok(Self::from_private_key(&private_key, network)?.with_dexes(dexes))
}
pub fn from_private_key(
private_key: &str,
network: Network,
) -> Result<Self, HyperliquidConfigError> {
let key = private_key.strip_prefix("0x").unwrap_or(private_key);
let wallet: LocalWallet = key
.parse()
.map_err(|e| HyperliquidConfigError::InvalidPrivateKey(format!("{e}")))?;
Ok(Self::new(wallet, network))
}
pub fn wallet_address_hex(&self) -> String {
format!("{:#x}", self.wallet.address())
}
pub(super) fn base_url(&self) -> BaseUrl {
match self.network {
Network::Mainnet => BaseUrl::Mainnet,
Network::Testnet => BaseUrl::Testnet,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HyperliquidConfigFile {
#[serde(default = "default_network")]
pub network: Network,
#[serde(default)]
pub dexes: Vec<SmolStr>,
}
fn default_network() -> Network {
Network::Testnet
}
#[derive(Debug, PartialEq, thiserror::Error)]
pub enum HyperliquidConfigError {
#[error("HYPERLIQUID_PRIVATE_KEY environment variable not set")]
MissingPrivateKey,
#[error("HYPERLIQUID_PRIVATE_KEY environment variable is not valid UTF-8")]
InvalidPrivateKeyVar,
#[error("Invalid private key: {0}")]
InvalidPrivateKey(String),
#[error("HYPERLIQUID_TESTNET must be true or false, got {0}")]
InvalidTestnet(String),
#[error("HYPERLIQUID_DEXES environment variable is not valid UTF-8: {0}")]
InvalidDexes(String),
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
#[test]
fn test_from_private_key_with_prefix() {
let key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
let config = HyperliquidConfig::from_private_key(key, Network::Mainnet).unwrap();
assert_eq!(config.network, Network::Mainnet);
assert!(config.dexes.is_empty());
assert!(config.wallet_address_hex().starts_with("0x"));
}
#[test]
fn test_from_private_key_without_prefix() {
let key = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
let config = HyperliquidConfig::from_private_key(key, Network::Testnet).unwrap();
assert_eq!(config.network, Network::Testnet);
}
#[test]
fn test_invalid_private_key() {
let result = HyperliquidConfig::from_private_key("invalid", Network::Mainnet);
assert!(result.is_err());
}
const TEST_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
#[test]
#[serial_test::serial]
fn test_from_env_defaults_to_testnet() {
temp_env::with_vars(
[
("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
("HYPERLIQUID_TESTNET", None),
],
|| {
let cfg = HyperliquidConfig::from_env().unwrap();
assert_eq!(
cfg.network,
Network::Testnet,
"absent toggle must default to the safe testnet"
);
},
);
}
#[test]
#[serial_test::serial]
fn test_from_env_accepts_explicit_mainnet() {
temp_env::with_vars(
[
("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
("HYPERLIQUID_TESTNET", Some("false")),
],
|| {
let cfg = HyperliquidConfig::from_env().unwrap();
assert_eq!(cfg.network, Network::Mainnet);
},
);
}
#[test]
#[serial_test::serial]
fn test_from_env_rejects_invalid_testnet() {
temp_env::with_vars(
[
("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
("HYPERLIQUID_TESTNET", Some("maybe")),
],
|| {
let err = HyperliquidConfig::from_env().unwrap_err();
assert!(
matches!(err, HyperliquidConfigError::InvalidTestnet(value) if value == "maybe")
);
},
);
}
#[test]
#[serial_test::serial]
fn test_from_env_drops_numeric_one_special_case() {
temp_env::with_vars(
[
("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
("HYPERLIQUID_TESTNET", Some("1")),
],
|| {
let err = HyperliquidConfig::from_env().unwrap_err();
assert!(
matches!(err, HyperliquidConfigError::InvalidTestnet(value) if value == "1")
);
},
);
}
#[test]
#[serial_test::serial]
fn test_from_env_requires_private_key() {
temp_env::with_vars(
[
("HYPERLIQUID_PRIVATE_KEY", None),
("HYPERLIQUID_TESTNET", Some("true")),
],
|| {
let err = HyperliquidConfig::from_env().unwrap_err();
assert!(matches!(err, HyperliquidConfigError::MissingPrivateKey));
},
);
}
#[test]
#[serial_test::serial]
fn from_env_reads_the_dexes_to_trade() {
temp_env::with_vars(
[
("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
("HYPERLIQUID_TESTNET", None),
("HYPERLIQUID_DEXES", Some(" xyz, ,flx ,")),
],
|| {
let cfg = HyperliquidConfig::from_env().unwrap();
assert_eq!(cfg.dexes, ["xyz", "flx"]);
},
);
}
#[cfg(unix)]
#[test]
#[serial_test::serial]
fn from_env_rejects_non_utf8_dexes() {
use std::os::unix::ffi::OsStringExt;
let dexes = std::ffi::OsString::from_vec(vec![b'x', 0xff]);
temp_env::with_vars(
[
(
"HYPERLIQUID_PRIVATE_KEY",
Some(std::ffi::OsString::from(TEST_KEY)),
),
("HYPERLIQUID_DEXES", Some(dexes)),
],
|| {
let err = HyperliquidConfig::from_env().unwrap_err();
assert!(matches!(err, HyperliquidConfigError::InvalidDexes(_)));
},
);
}
#[test]
#[serial_test::serial]
fn from_env_trades_no_dex_when_none_is_named() {
temp_env::with_vars(
[
("HYPERLIQUID_PRIVATE_KEY", Some(TEST_KEY)),
("HYPERLIQUID_DEXES", None),
],
|| assert!(HyperliquidConfig::from_env().unwrap().dexes.is_empty()),
);
}
#[test]
fn with_dexes_replaces_the_dexes_named_before() {
let config = HyperliquidConfig::from_private_key(TEST_KEY, Network::Testnet)
.unwrap()
.with_dexes(["xyz"])
.with_dexes(["flx", "km"]);
assert_eq!(config.dexes, ["flx", "km"]);
}
#[test]
fn test_config_file_absent_network_defaults_to_testnet() {
let file: HyperliquidConfigFile = serde_json::from_str("{}").unwrap();
assert_eq!(
file.network,
Network::Testnet,
"absent `network` field must default to safe testnet"
);
assert!(file.dexes.is_empty());
}
#[test]
fn a_config_file_from_before_network_fails_to_load() {
let file = serde_json::from_str::<HyperliquidConfigFile>(r#"{"testnet": false}"#);
assert!(file.unwrap_err().to_string().contains("testnet"));
}
#[test]
fn a_config_file_names_its_network_and_dexes() {
let file: HyperliquidConfigFile =
serde_json::from_str(r#"{"network": "mainnet", "dexes": ["xyz", "flx"]}"#).unwrap();
assert_eq!(file.network, Network::Mainnet);
assert_eq!(file.dexes, ["xyz", "flx"]);
}
}