├── .gitignore ├── rust-toolchain.toml ├── src ├── prelude.rs ├── signature │ ├── mod.rs │ ├── agent.rs │ └── create_signature.rs ├── info │ ├── mod.rs │ ├── response_structs.rs │ ├── sub_structs.rs │ └── info_client.rs ├── ws │ ├── mod.rs │ ├── message_types.rs │ ├── sub_structs.rs │ └── ws_manager.rs ├── consts.rs ├── exchange │ ├── builder.rs │ ├── modify.rs │ ├── mod.rs │ ├── cancel.rs │ ├── exchange_responses.rs │ ├── order.rs │ ├── actions.rs │ └── exchange_client.rs ├── eip712.rs ├── lib.rs ├── bin │ ├── using_big_blocks.rs │ ├── class_transfer.rs │ ├── set_referrer.rs │ ├── usdc_transfer.rs │ ├── bridge_withdraw.rs │ ├── ws_bbo.rs │ ├── approve_builder_fee.rs │ ├── spot_transfer.rs │ ├── ws_all_mids.rs │ ├── claim_rewards.rs │ ├── market_maker.rs │ ├── ws_active_asset_ctx.rs │ ├── ws_trades.rs │ ├── ws_l2_book.rs │ ├── ws_web_data2.rs │ ├── vault_transfer.rs │ ├── ws_user_events.rs │ ├── ws_notification.rs │ ├── ws_orders.rs │ ├── ws_candles.rs │ ├── ws_user_fundings.rs │ ├── ws_active_asset_data.rs │ ├── ws_spot_price.rs │ ├── ws_user_non_funding_ledger_updates.rs │ ├── leverage.rs │ ├── agent.rs │ ├── order_and_cancel_cloid.rs │ ├── order_and_cancel.rs │ ├── spot_order.rs │ ├── order_and_schedule_cancel.rs │ ├── order_with_builder_and_cancel.rs │ ├── market_order_and_cancel.rs │ ├── market_order_with_builder_and_cancel.rs │ └── info.rs ├── req.rs ├── errors.rs ├── meta.rs ├── helpers.rs └── market_maker.rs ├── ci.sh ├── .github └── workflows │ └── master.yml ├── README.md ├── Cargo.toml └── LICENSE.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea/ 3 | Cargo.lock -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | -------------------------------------------------------------------------------- /src/prelude.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::Error; 2 | 3 | pub(crate) type Result = std::result::Result; 4 | -------------------------------------------------------------------------------- /src/signature/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod agent; 2 | mod create_signature; 3 | 4 | pub(crate) use create_signature::{sign_l1_action, sign_typed_data}; 5 | -------------------------------------------------------------------------------- /src/info/mod.rs: -------------------------------------------------------------------------------- 1 | pub(super) mod info_client; 2 | mod response_structs; 3 | mod sub_structs; 4 | 5 | pub use response_structs::*; 6 | pub use sub_structs::*; 7 | -------------------------------------------------------------------------------- /src/ws/mod.rs: -------------------------------------------------------------------------------- 1 | mod message_types; 2 | mod sub_structs; 3 | mod ws_manager; 4 | pub use message_types::*; 5 | pub use sub_structs::*; 6 | pub(crate) use ws_manager::WsManager; 7 | pub use ws_manager::{Message, Subscription}; 8 | -------------------------------------------------------------------------------- /ci.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Build 6 | cargo build 7 | 8 | # Check formatting 9 | cargo fmt -- --check 10 | 11 | # Run Clippy 12 | cargo clippy -- -D warnings 13 | 14 | # Run tests 15 | cargo test 16 | 17 | echo "CI checks passed successfully." -------------------------------------------------------------------------------- /src/consts.rs: -------------------------------------------------------------------------------- 1 | pub static MAINNET_API_URL: &str = "https://api.hyperliquid.xyz"; 2 | pub static TESTNET_API_URL: &str = "https://api.hyperliquid-testnet.xyz"; 3 | pub static LOCAL_API_URL: &str = "http://localhost:3001"; 4 | pub const EPSILON: f64 = 1e-9; 5 | pub(crate) const INF_BPS: u16 = 10_001; 6 | -------------------------------------------------------------------------------- /src/exchange/builder.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Default, Deserialize, Serialize, Debug, Clone)] 4 | #[serde(rename_all = "camelCase")] 5 | pub struct BuilderInfo { 6 | #[serde(rename = "b")] 7 | pub builder: String, 8 | #[serde(rename = "f")] 9 | pub fee: u64, 10 | } 11 | -------------------------------------------------------------------------------- /src/exchange/modify.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use super::{order::OrderRequest, ClientOrderRequest}; 4 | 5 | #[derive(Debug)] 6 | pub struct ClientModifyRequest { 7 | pub oid: u64, 8 | pub order: ClientOrderRequest, 9 | } 10 | 11 | #[derive(Serialize, Deserialize, Debug, Clone)] 12 | pub struct ModifyRequest { 13 | pub oid: u64, 14 | pub order: OrderRequest, 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/master.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | ubuntu: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions-rs/toolchain@v1 17 | with: 18 | toolchain: stable 19 | components: clippy,rustfmt 20 | - run: ./ci.sh -------------------------------------------------------------------------------- /src/exchange/mod.rs: -------------------------------------------------------------------------------- 1 | mod actions; 2 | mod builder; 3 | mod cancel; 4 | mod exchange_client; 5 | mod exchange_responses; 6 | mod modify; 7 | mod order; 8 | 9 | pub use actions::*; 10 | pub use builder::*; 11 | pub use cancel::{ClientCancelRequest, ClientCancelRequestCloid}; 12 | pub use exchange_client::*; 13 | pub use exchange_responses::*; 14 | pub use modify::{ClientModifyRequest, ModifyRequest}; 15 | pub use order::{ 16 | ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, MarketCloseParams, 17 | MarketOrderParams, Order, 18 | }; 19 | -------------------------------------------------------------------------------- /src/eip712.rs: -------------------------------------------------------------------------------- 1 | use alloy::{ 2 | dyn_abi::Eip712Domain, 3 | primitives::{keccak256, B256}, 4 | }; 5 | 6 | pub trait Eip712 { 7 | fn domain(&self) -> Eip712Domain; 8 | fn struct_hash(&self) -> B256; 9 | 10 | fn eip712_signing_hash(&self) -> B256 { 11 | let mut digest_input = [0u8; 2 + 32 + 32]; 12 | digest_input[0] = 0x19; 13 | digest_input[1] = 0x01; 14 | digest_input[2..34].copy_from_slice(&self.domain().hash_struct()[..]); 15 | digest_input[34..66].copy_from_slice(&self.struct_hash()[..]); 16 | keccak256(digest_input) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(unreachable_pub)] 2 | mod consts; 3 | mod eip712; 4 | mod errors; 5 | mod exchange; 6 | mod helpers; 7 | mod info; 8 | mod market_maker; 9 | mod meta; 10 | mod prelude; 11 | mod req; 12 | mod signature; 13 | mod ws; 14 | pub use consts::{EPSILON, LOCAL_API_URL, MAINNET_API_URL, TESTNET_API_URL}; 15 | pub use eip712::Eip712; 16 | pub use errors::Error; 17 | pub use exchange::*; 18 | pub use helpers::{bps_diff, truncate_float, BaseUrl}; 19 | pub use info::{info_client::*, *}; 20 | pub use market_maker::{MarketMaker, MarketMakerInput, MarketMakerRestingOrder}; 21 | pub use meta::{AssetContext, AssetMeta, Meta, MetaAndAssetCtxs, SpotAssetMeta, SpotMeta}; 22 | pub use ws::*; 23 | -------------------------------------------------------------------------------- /src/exchange/cancel.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use uuid::Uuid; 3 | 4 | #[derive(Debug)] 5 | pub struct ClientCancelRequest { 6 | pub asset: String, 7 | pub oid: u64, 8 | } 9 | 10 | #[derive(Serialize, Deserialize, Debug, Clone)] 11 | pub struct CancelRequest { 12 | #[serde(rename = "a", alias = "asset")] 13 | pub asset: u32, 14 | #[serde(rename = "o", alias = "oid")] 15 | pub oid: u64, 16 | } 17 | 18 | #[derive(Debug)] 19 | pub struct ClientCancelRequestCloid { 20 | pub asset: String, 21 | pub cloid: Uuid, 22 | } 23 | 24 | #[derive(Serialize, Deserialize, Debug, Clone)] 25 | pub struct CancelRequestCloid { 26 | pub asset: u32, 27 | pub cloid: String, 28 | } 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hyperliquid-rust-sdk 2 | 3 | SDK for Hyperliquid API trading with Rust. 4 | 5 | ## Usage Examples 6 | 7 | See `src/bin` for examples. You can run any example with `cargo run --bin [EXAMPLE]`. 8 | 9 | ## Installation 10 | 11 | `cargo add hyperliquid_rust_sdk` 12 | 13 | ## License 14 | 15 | This project is licensed under the terms of the `MIT` license. See [LICENSE](LICENSE.md) for more details. 16 | 17 | ```bibtex 18 | @misc{hyperliquid-rust-sdk, 19 | author = {Hyperliquid}, 20 | title = {SDK for Hyperliquid API trading with Rust.}, 21 | year = {2024}, 22 | publisher = {GitHub}, 23 | journal = {GitHub repository}, 24 | howpublished = {\url{https://github.com/hyperliquid-dex/hyperliquid-rust-sdk}} 25 | } 26 | ``` 27 | -------------------------------------------------------------------------------- /src/bin/using_big_blocks.rs: -------------------------------------------------------------------------------- 1 | use alloy::signers::local::PrivateKeySigner; 2 | use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; 3 | use log::info; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | env_logger::init(); 8 | // Key was randomly generated for testing and shouldn't be used with any real funds 9 | let wallet: PrivateKeySigner = 10 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 11 | .parse() 12 | .unwrap(); 13 | 14 | let exchange_client = 15 | ExchangeClient::new(None, wallet.clone(), Some(BaseUrl::Testnet), None, None) 16 | .await 17 | .unwrap(); 18 | 19 | let res = exchange_client 20 | .enable_big_blocks(false, Some(&wallet)) 21 | .await 22 | .unwrap(); 23 | info!("enable big blocks : {res:?}"); 24 | } 25 | -------------------------------------------------------------------------------- /src/signature/agent.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod l1 { 2 | use alloy::{ 3 | dyn_abi::Eip712Domain, 4 | primitives::{Address, B256}, 5 | sol, 6 | sol_types::{eip712_domain, SolStruct}, 7 | }; 8 | 9 | use crate::eip712::Eip712; 10 | 11 | sol! { 12 | #[derive(Debug)] 13 | struct Agent { 14 | string source; 15 | bytes32 connectionId; 16 | } 17 | } 18 | 19 | impl Eip712 for Agent { 20 | fn domain(&self) -> Eip712Domain { 21 | eip712_domain! { 22 | name: "Exchange", 23 | version: "1", 24 | chain_id: 1337, 25 | verifying_contract: Address::ZERO, 26 | } 27 | } 28 | fn struct_hash(&self) -> B256 { 29 | self.eip712_hash_struct() 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/bin/class_transfer.rs: -------------------------------------------------------------------------------- 1 | use alloy::signers::local::PrivateKeySigner; 2 | use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; 3 | use log::info; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | env_logger::init(); 8 | // Key was randomly generated for testing and shouldn't be used with any real funds 9 | let wallet: PrivateKeySigner = 10 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 11 | .parse() 12 | .unwrap(); 13 | 14 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 15 | .await 16 | .unwrap(); 17 | 18 | let usdc = 1.0; // 1 USD 19 | let to_perp = false; 20 | 21 | let res = exchange_client 22 | .class_transfer(usdc, to_perp, None) 23 | .await 24 | .unwrap(); 25 | info!("Class transfer result: {res:?}"); 26 | } 27 | -------------------------------------------------------------------------------- /src/bin/set_referrer.rs: -------------------------------------------------------------------------------- 1 | use alloy::signers::local::PrivateKeySigner; 2 | use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; 3 | use log::info; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | env_logger::init(); 8 | // Key was randomly generated for testing and shouldn't be used with any real funds 9 | let wallet: PrivateKeySigner = 10 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 11 | .parse() 12 | .unwrap(); 13 | 14 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 15 | .await 16 | .unwrap(); 17 | 18 | let code = "TESTNET".to_string(); 19 | 20 | let res = exchange_client.set_referrer(code, None).await; 21 | 22 | if let Ok(res) = res { 23 | info!("Exchange response: {res:#?}"); 24 | } else { 25 | info!("Got error: {:#?}", res.err().unwrap()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/bin/usdc_transfer.rs: -------------------------------------------------------------------------------- 1 | use alloy::signers::local::PrivateKeySigner; 2 | use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; 3 | use log::info; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | env_logger::init(); 8 | // Key was randomly generated for testing and shouldn't be used with any real funds 9 | let wallet: PrivateKeySigner = 10 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 11 | .parse() 12 | .unwrap(); 13 | 14 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 15 | .await 16 | .unwrap(); 17 | 18 | let amount = "1"; // 1 USD 19 | let destination = "0x0D1d9635D0640821d15e323ac8AdADfA9c111414"; 20 | 21 | let res = exchange_client 22 | .usdc_transfer(amount, destination, None) 23 | .await 24 | .unwrap(); 25 | info!("Usdc transfer result: {res:?}"); 26 | } 27 | -------------------------------------------------------------------------------- /src/bin/bridge_withdraw.rs: -------------------------------------------------------------------------------- 1 | use alloy::signers::local::PrivateKeySigner; 2 | use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; 3 | use log::info; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | env_logger::init(); 8 | // Key was randomly generated for testing and shouldn't be used with any real funds 9 | let wallet: PrivateKeySigner = 10 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 11 | .parse() 12 | .unwrap(); 13 | 14 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 15 | .await 16 | .unwrap(); 17 | 18 | let usd = "5"; // 5 USD 19 | let destination = "0x0D1d9635D0640821d15e323ac8AdADfA9c111414"; 20 | 21 | let res = exchange_client 22 | .withdraw_from_bridge(usd, destination, None) 23 | .await 24 | .unwrap(); 25 | info!("Withdraw from bridge result: {res:?}"); 26 | } 27 | -------------------------------------------------------------------------------- /src/bin/ws_bbo.rs: -------------------------------------------------------------------------------- 1 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 2 | use log::info; 3 | use tokio::{ 4 | spawn, 5 | sync::mpsc::unbounded_channel, 6 | time::{sleep, Duration}, 7 | }; 8 | 9 | #[tokio::main] 10 | async fn main() { 11 | env_logger::init(); 12 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 13 | let coin = "BTC".to_string(); 14 | 15 | let (sender, mut receiver) = unbounded_channel(); 16 | let subscription_id = info_client 17 | .subscribe(Subscription::Bbo { coin }, sender) 18 | .await 19 | .unwrap(); 20 | 21 | spawn(async move { 22 | sleep(Duration::from_secs(30)).await; 23 | info!("Unsubscribing from bbo"); 24 | info_client.unsubscribe(subscription_id).await.unwrap() 25 | }); 26 | 27 | while let Some(Message::Bbo(bbo)) = receiver.recv().await { 28 | info!("Received bbo: {bbo:?}"); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/bin/approve_builder_fee.rs: -------------------------------------------------------------------------------- 1 | use alloy::{primitives::address, signers::local::PrivateKeySigner}; 2 | use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; 3 | use log::info; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | env_logger::init(); 8 | // Key was randomly generated for testing and shouldn't be used with any real funds 9 | let wallet: PrivateKeySigner = 10 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 11 | .parse() 12 | .unwrap(); 13 | 14 | let exchange_client = 15 | ExchangeClient::new(None, wallet.clone(), Some(BaseUrl::Testnet), None, None) 16 | .await 17 | .unwrap(); 18 | 19 | let max_fee_rate = "0.1%"; 20 | let builder = address!("0x1ab189B7801140900C711E458212F9c76F8dAC79"); 21 | 22 | let resp = exchange_client 23 | .approve_builder_fee(builder, max_fee_rate.to_string(), Some(&wallet)) 24 | .await; 25 | info!("resp: {resp:#?}"); 26 | } 27 | -------------------------------------------------------------------------------- /src/bin/spot_transfer.rs: -------------------------------------------------------------------------------- 1 | use alloy::signers::local::PrivateKeySigner; 2 | use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; 3 | use log::info; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | env_logger::init(); 8 | // Key was randomly generated for testing and shouldn't be used with any real funds 9 | let wallet: PrivateKeySigner = 10 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 11 | .parse() 12 | .unwrap(); 13 | 14 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 15 | .await 16 | .unwrap(); 17 | 18 | let amount = "1"; 19 | let destination = "0x0D1d9635D0640821d15e323ac8AdADfA9c111414"; 20 | let token = "PURR:0xc4bf3f870c0e9465323c0b6ed28096c2"; 21 | 22 | let res = exchange_client 23 | .spot_transfer(amount, destination, token, None) 24 | .await 25 | .unwrap(); 26 | info!("Spot transfer result: {res:?}"); 27 | } 28 | -------------------------------------------------------------------------------- /src/bin/ws_all_mids.rs: -------------------------------------------------------------------------------- 1 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 2 | use log::info; 3 | use tokio::{ 4 | spawn, 5 | sync::mpsc::unbounded_channel, 6 | time::{sleep, Duration}, 7 | }; 8 | 9 | #[tokio::main] 10 | async fn main() { 11 | env_logger::init(); 12 | 13 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 14 | 15 | let (sender, mut receiver) = unbounded_channel(); 16 | let subscription_id = info_client 17 | .subscribe(Subscription::AllMids, sender) 18 | .await 19 | .unwrap(); 20 | 21 | spawn(async move { 22 | sleep(Duration::from_secs(30)).await; 23 | info!("Unsubscribing from mids data"); 24 | info_client.unsubscribe(subscription_id).await.unwrap() 25 | }); 26 | 27 | // This loop ends when we unsubscribe 28 | while let Some(Message::AllMids(all_mids)) = receiver.recv().await { 29 | info!("Received mids data: {all_mids:?}"); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/bin/claim_rewards.rs: -------------------------------------------------------------------------------- 1 | use alloy::signers::local::PrivateKeySigner; 2 | use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient, ExchangeResponseStatus}; 3 | use log::info; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | env_logger::init(); 8 | // Key was randomly generated for testing and shouldn't be used with any real funds 9 | let wallet: PrivateKeySigner = 10 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 11 | .parse() 12 | .unwrap(); 13 | 14 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 15 | .await 16 | .unwrap(); 17 | 18 | let response = exchange_client.claim_rewards(None).await.unwrap(); 19 | 20 | match response { 21 | ExchangeResponseStatus::Ok(exchange_response) => { 22 | info!("Rewards claimed successfully: {exchange_response:?}"); 23 | } 24 | ExchangeResponseStatus::Err(e) => { 25 | info!("Failed to claim rewards: {e}"); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/bin/market_maker.rs: -------------------------------------------------------------------------------- 1 | /* 2 | This is an example of a basic market making strategy. 3 | 4 | We subscribe to the current mid price and build a market around this price. Whenever our market becomes outdated, we place and cancel orders to renew it. 5 | */ 6 | use alloy::signers::local::PrivateKeySigner; 7 | use hyperliquid_rust_sdk::{MarketMaker, MarketMakerInput}; 8 | 9 | #[tokio::main] 10 | async fn main() { 11 | env_logger::init(); 12 | // Key was randomly generated for testing and shouldn't be used with any real funds 13 | let wallet: PrivateKeySigner = 14 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 15 | .parse() 16 | .unwrap(); 17 | let market_maker_input = MarketMakerInput { 18 | asset: "ETH".to_string(), 19 | target_liquidity: 0.25, 20 | max_bps_diff: 2, 21 | half_spread: 1, 22 | max_absolute_position_size: 0.5, 23 | decimals: 1, 24 | wallet, 25 | }; 26 | MarketMaker::new(market_maker_input).await.start().await 27 | } 28 | -------------------------------------------------------------------------------- /src/bin/ws_active_asset_ctx.rs: -------------------------------------------------------------------------------- 1 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 2 | use log::info; 3 | use tokio::{ 4 | spawn, 5 | sync::mpsc::unbounded_channel, 6 | time::{sleep, Duration}, 7 | }; 8 | 9 | #[tokio::main] 10 | async fn main() { 11 | env_logger::init(); 12 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 13 | let coin = "BTC".to_string(); 14 | 15 | let (sender, mut receiver) = unbounded_channel(); 16 | let subscription_id = info_client 17 | .subscribe(Subscription::ActiveAssetCtx { coin }, sender) 18 | .await 19 | .unwrap(); 20 | 21 | spawn(async move { 22 | sleep(Duration::from_secs(30)).await; 23 | info!("Unsubscribing from active asset ctx"); 24 | info_client.unsubscribe(subscription_id).await.unwrap() 25 | }); 26 | 27 | while let Some(Message::ActiveAssetCtx(active_asset_ctx)) = receiver.recv().await { 28 | info!("Received active asset ctx: {active_asset_ctx:?}"); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hyperliquid_rust_sdk" 3 | version = "0.6.0" 4 | edition = "2021" 5 | license = "MIT" 6 | description = "Rust SDK for Hyperliquid" 7 | homepage = "https://hyperliquid.xyz/" 8 | readme = "README.md" 9 | documentation = "https://github.com/hyperliquid-dex/hyperliquid-rust-sdk" 10 | repository = "https://github.com/hyperliquid-dex/hyperliquid-rust-sdk" 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [dependencies] 15 | alloy = { version = "1.0", default-features = false, features = [ 16 | "dyn-abi", 17 | "sol-types", 18 | "signer-local", 19 | ] } 20 | chrono = "0.4.26" 21 | env_logger = "0.11.8" 22 | futures-util = "0.3.28" 23 | lazy_static = "1.0" 24 | log = "0.4.19" 25 | reqwest = "0.12.19" 26 | serde = { version = "1.0", features = ["derive"] } 27 | serde_json = "1.0" 28 | rmp-serde = "1.0" 29 | thiserror = "2.0" 30 | tokio = { version = "1.0", features = ["full"] } 31 | tokio-tungstenite = { version = "0.20.0", features = ["native-tls"] } 32 | uuid = { version = "1.0", features = ["v4"] } 33 | -------------------------------------------------------------------------------- /src/bin/ws_trades.rs: -------------------------------------------------------------------------------- 1 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 2 | use log::info; 3 | use tokio::{ 4 | spawn, 5 | sync::mpsc::unbounded_channel, 6 | time::{sleep, Duration}, 7 | }; 8 | 9 | #[tokio::main] 10 | async fn main() { 11 | env_logger::init(); 12 | 13 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 14 | 15 | let (sender, mut receiver) = unbounded_channel(); 16 | let subscription_id = info_client 17 | .subscribe( 18 | Subscription::Trades { 19 | coin: "ETH".to_string(), 20 | }, 21 | sender, 22 | ) 23 | .await 24 | .unwrap(); 25 | 26 | spawn(async move { 27 | sleep(Duration::from_secs(30)).await; 28 | info!("Unsubscribing from trades data"); 29 | info_client.unsubscribe(subscription_id).await.unwrap() 30 | }); 31 | 32 | // This loop ends when we unsubscribe 33 | while let Some(Message::Trades(trades)) = receiver.recv().await { 34 | info!("Received trade data: {trades:?}"); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/bin/ws_l2_book.rs: -------------------------------------------------------------------------------- 1 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 2 | use log::info; 3 | use tokio::{ 4 | spawn, 5 | sync::mpsc::unbounded_channel, 6 | time::{sleep, Duration}, 7 | }; 8 | 9 | #[tokio::main] 10 | async fn main() { 11 | env_logger::init(); 12 | 13 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 14 | 15 | let (sender, mut receiver) = unbounded_channel(); 16 | let subscription_id = info_client 17 | .subscribe( 18 | Subscription::L2Book { 19 | coin: "ETH".to_string(), 20 | }, 21 | sender, 22 | ) 23 | .await 24 | .unwrap(); 25 | 26 | spawn(async move { 27 | sleep(Duration::from_secs(30)).await; 28 | info!("Unsubscribing from l2 book data"); 29 | info_client.unsubscribe(subscription_id).await.unwrap() 30 | }); 31 | 32 | // This loop ends when we unsubscribe 33 | while let Some(Message::L2Book(l2_book)) = receiver.recv().await { 34 | info!("Received l2 book data: {l2_book:?}"); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/bin/ws_web_data2.rs: -------------------------------------------------------------------------------- 1 | use alloy::primitives::address; 2 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 3 | use log::info; 4 | use tokio::{ 5 | spawn, 6 | sync::mpsc::unbounded_channel, 7 | time::{sleep, Duration}, 8 | }; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | env_logger::init(); 13 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 14 | let user = address!("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"); 15 | 16 | let (sender, mut receiver) = unbounded_channel(); 17 | let subscription_id = info_client 18 | .subscribe(Subscription::WebData2 { user }, sender) 19 | .await 20 | .unwrap(); 21 | 22 | spawn(async move { 23 | sleep(Duration::from_secs(30)).await; 24 | info!("Unsubscribing from web data2"); 25 | info_client.unsubscribe(subscription_id).await.unwrap() 26 | }); 27 | 28 | // this loop ends when we unsubscribe 29 | while let Some(Message::WebData2(web_data2)) = receiver.recv().await { 30 | info!("Received web data: {web_data2:?}"); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/bin/vault_transfer.rs: -------------------------------------------------------------------------------- 1 | use alloy::signers::local::PrivateKeySigner; 2 | use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; 3 | use log::info; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | env_logger::init(); 8 | // Key was randomly generated for testing and shouldn't be used with any real funds 9 | let wallet: PrivateKeySigner = 10 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 11 | .parse() 12 | .unwrap(); 13 | 14 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 15 | .await 16 | .unwrap(); 17 | 18 | let usd = 5_000_000; // at least 5 USD 19 | let is_deposit = true; 20 | 21 | let res = exchange_client 22 | .vault_transfer( 23 | is_deposit, 24 | usd, 25 | Some( 26 | "0x1962905b0a2d0ce7907ae1a0d17f3e4a1f63dfb7" 27 | .parse() 28 | .unwrap(), 29 | ), 30 | None, 31 | ) 32 | .await 33 | .unwrap(); 34 | info!("Vault transfer result: {res:?}"); 35 | } 36 | -------------------------------------------------------------------------------- /src/bin/ws_user_events.rs: -------------------------------------------------------------------------------- 1 | use alloy::primitives::address; 2 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 3 | use log::info; 4 | use tokio::{ 5 | spawn, 6 | sync::mpsc::unbounded_channel, 7 | time::{sleep, Duration}, 8 | }; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | env_logger::init(); 13 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 14 | let user = address!("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"); 15 | 16 | let (sender, mut receiver) = unbounded_channel(); 17 | let subscription_id = info_client 18 | .subscribe(Subscription::UserEvents { user }, sender) 19 | .await 20 | .unwrap(); 21 | 22 | spawn(async move { 23 | sleep(Duration::from_secs(30)).await; 24 | info!("Unsubscribing from user events data"); 25 | info_client.unsubscribe(subscription_id).await.unwrap() 26 | }); 27 | 28 | // this loop ends when we unsubscribe 29 | while let Some(Message::User(user_event)) = receiver.recv().await { 30 | info!("Received user event data: {user_event:?}"); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/bin/ws_notification.rs: -------------------------------------------------------------------------------- 1 | use alloy::primitives::address; 2 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 3 | use log::info; 4 | use tokio::{ 5 | spawn, 6 | sync::mpsc::unbounded_channel, 7 | time::{sleep, Duration}, 8 | }; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | env_logger::init(); 13 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 14 | let user = address!("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"); 15 | 16 | let (sender, mut receiver) = unbounded_channel(); 17 | let subscription_id = info_client 18 | .subscribe(Subscription::Notification { user }, sender) 19 | .await 20 | .unwrap(); 21 | 22 | spawn(async move { 23 | sleep(Duration::from_secs(30)).await; 24 | info!("Unsubscribing from notification data"); 25 | info_client.unsubscribe(subscription_id).await.unwrap() 26 | }); 27 | 28 | // this loop ends when we unsubscribe 29 | while let Some(Message::Notification(notification)) = receiver.recv().await { 30 | info!("Received notification data: {notification:?}"); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/bin/ws_orders.rs: -------------------------------------------------------------------------------- 1 | use alloy::primitives::address; 2 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 3 | use log::info; 4 | use tokio::{ 5 | spawn, 6 | sync::mpsc::unbounded_channel, 7 | time::{sleep, Duration}, 8 | }; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | env_logger::init(); 13 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 14 | let user = address!("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"); 15 | 16 | let (sender, mut receiver) = unbounded_channel(); 17 | let subscription_id = info_client 18 | .subscribe(Subscription::OrderUpdates { user }, sender) 19 | .await 20 | .unwrap(); 21 | 22 | spawn(async move { 23 | sleep(Duration::from_secs(30)).await; 24 | info!("Unsubscribing from order updates data"); 25 | info_client.unsubscribe(subscription_id).await.unwrap() 26 | }); 27 | 28 | // this loop ends when we unsubscribe 29 | while let Some(Message::OrderUpdates(order_updates)) = receiver.recv().await { 30 | info!("Received order update data: {order_updates:?}"); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/bin/ws_candles.rs: -------------------------------------------------------------------------------- 1 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 2 | use log::info; 3 | use tokio::{ 4 | spawn, 5 | sync::mpsc::unbounded_channel, 6 | time::{sleep, Duration}, 7 | }; 8 | 9 | #[tokio::main] 10 | async fn main() { 11 | env_logger::init(); 12 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Mainnet)).await.unwrap(); 13 | 14 | let (sender, mut receiver) = unbounded_channel(); 15 | let subscription_id = info_client 16 | .subscribe( 17 | Subscription::Candle { 18 | coin: "ETH".to_string(), 19 | interval: "1m".to_string(), 20 | }, 21 | sender, 22 | ) 23 | .await 24 | .unwrap(); 25 | 26 | spawn(async move { 27 | sleep(Duration::from_secs(300)).await; 28 | info!("Unsubscribing from candle data"); 29 | info_client.unsubscribe(subscription_id).await.unwrap() 30 | }); 31 | 32 | // This loop ends when we unsubscribe 33 | while let Some(Message::Candle(candle)) = receiver.recv().await { 34 | info!("Received candle data: {candle:?}"); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/bin/ws_user_fundings.rs: -------------------------------------------------------------------------------- 1 | use alloy::primitives::address; 2 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 3 | use log::info; 4 | use tokio::{ 5 | spawn, 6 | sync::mpsc::unbounded_channel, 7 | time::{sleep, Duration}, 8 | }; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | env_logger::init(); 13 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 14 | let user = address!("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"); 15 | 16 | let (sender, mut receiver) = unbounded_channel(); 17 | let subscription_id = info_client 18 | .subscribe(Subscription::UserFundings { user }, sender) 19 | .await 20 | .unwrap(); 21 | 22 | spawn(async move { 23 | sleep(Duration::from_secs(30)).await; 24 | info!("Unsubscribing from user fundings data"); 25 | info_client.unsubscribe(subscription_id).await.unwrap() 26 | }); 27 | 28 | // this loop ends when we unsubscribe 29 | while let Some(Message::UserFundings(user_fundings)) = receiver.recv().await { 30 | info!("Received user fundings data: {user_fundings:?}"); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Hyperliquid Labs Pte. Ltd. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 19 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 20 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 21 | OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/bin/ws_active_asset_data.rs: -------------------------------------------------------------------------------- 1 | use alloy::primitives::address; 2 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 3 | use log::info; 4 | use tokio::{ 5 | spawn, 6 | sync::mpsc::unbounded_channel, 7 | time::{sleep, Duration}, 8 | }; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | env_logger::init(); 13 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 14 | let user = address!("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"); 15 | let coin = "BTC".to_string(); 16 | 17 | let (sender, mut receiver) = unbounded_channel(); 18 | let subscription_id = info_client 19 | .subscribe(Subscription::ActiveAssetData { user, coin }, sender) 20 | .await 21 | .unwrap(); 22 | 23 | spawn(async move { 24 | sleep(Duration::from_secs(30)).await; 25 | info!("Unsubscribing from active asset data"); 26 | info_client.unsubscribe(subscription_id).await.unwrap() 27 | }); 28 | 29 | while let Some(Message::ActiveAssetData(active_asset_data)) = receiver.recv().await { 30 | info!("Received active asset data: {active_asset_data:?}"); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/bin/ws_spot_price.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 4 | use log::info; 5 | use tokio::{spawn, sync::mpsc::unbounded_channel, time::sleep}; 6 | 7 | #[tokio::main] 8 | async fn main() { 9 | env_logger::init(); 10 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Mainnet)).await.unwrap(); 11 | 12 | let (sender, mut receiver) = unbounded_channel(); 13 | let subscription_id = info_client 14 | .subscribe( 15 | Subscription::ActiveAssetCtx { 16 | coin: "@107".to_string(), //spot index for hype token 17 | }, 18 | sender, 19 | ) 20 | .await 21 | .unwrap(); 22 | 23 | spawn(async move { 24 | sleep(Duration::from_secs(30)).await; 25 | info!("Unsubscribing from order updates data"); 26 | info_client.unsubscribe(subscription_id).await.unwrap() 27 | }); 28 | 29 | // this loop ends when we unsubscribe 30 | while let Some(Message::ActiveSpotAssetCtx(order_updates)) = receiver.recv().await { 31 | info!("Received order update data: {order_updates:?}"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/exchange/exchange_responses.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Deserialize, Debug, Clone)] 4 | pub struct RestingOrder { 5 | pub oid: u64, 6 | } 7 | 8 | #[derive(Deserialize, Debug, Clone)] 9 | #[serde(rename_all = "camelCase")] 10 | pub struct FilledOrder { 11 | pub total_sz: String, 12 | pub avg_px: String, 13 | pub oid: u64, 14 | } 15 | 16 | #[derive(Deserialize, Debug, Clone)] 17 | #[serde(rename_all = "camelCase")] 18 | pub enum ExchangeDataStatus { 19 | Success, 20 | WaitingForFill, 21 | WaitingForTrigger, 22 | Error(String), 23 | Resting(RestingOrder), 24 | Filled(FilledOrder), 25 | } 26 | 27 | #[derive(Deserialize, Debug, Clone)] 28 | pub struct ExchangeDataStatuses { 29 | pub statuses: Vec, 30 | } 31 | 32 | #[derive(Deserialize, Debug, Clone)] 33 | pub struct ExchangeResponse { 34 | #[serde(rename = "type")] 35 | pub response_type: String, 36 | pub data: Option, 37 | } 38 | 39 | #[derive(Deserialize, Debug, Clone)] 40 | #[serde(rename_all = "camelCase")] 41 | #[serde(tag = "status", content = "response")] 42 | pub enum ExchangeResponseStatus { 43 | Ok(ExchangeResponse), 44 | Err(String), 45 | } 46 | -------------------------------------------------------------------------------- /src/bin/ws_user_non_funding_ledger_updates.rs: -------------------------------------------------------------------------------- 1 | use alloy::primitives::address; 2 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 3 | use log::info; 4 | use tokio::{ 5 | spawn, 6 | sync::mpsc::unbounded_channel, 7 | time::{sleep, Duration}, 8 | }; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | env_logger::init(); 13 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 14 | let user = address!("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"); 15 | 16 | let (sender, mut receiver) = unbounded_channel(); 17 | let subscription_id = info_client 18 | .subscribe(Subscription::UserNonFundingLedgerUpdates { user }, sender) 19 | .await 20 | .unwrap(); 21 | 22 | spawn(async move { 23 | sleep(Duration::from_secs(30)).await; 24 | info!("Unsubscribing from user non funding ledger update data"); 25 | info_client.unsubscribe(subscription_id).await.unwrap() 26 | }); 27 | 28 | // this loop ends when we unsubscribe 29 | while let Some(Message::UserNonFundingLedgerUpdates(user_non_funding_ledger_update)) = 30 | receiver.recv().await 31 | { 32 | info!("Received user non funding ledger update data: {user_non_funding_ledger_update:?}"); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/bin/leverage.rs: -------------------------------------------------------------------------------- 1 | use alloy::signers::local::PrivateKeySigner; 2 | use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient, InfoClient}; 3 | use log::info; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | // Example assumes you already have a position on ETH so you can update margin 8 | env_logger::init(); 9 | // Key was randomly generated for testing and shouldn't be used with any real funds 10 | let wallet: PrivateKeySigner = 11 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 12 | .parse() 13 | .unwrap(); 14 | 15 | let address = wallet.address(); 16 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 17 | .await 18 | .unwrap(); 19 | let info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 20 | 21 | let response = exchange_client 22 | .update_leverage(5, "ETH", false, None) 23 | .await 24 | .unwrap(); 25 | info!("Update leverage response: {response:?}"); 26 | 27 | let response = exchange_client 28 | .update_isolated_margin(1.0, "ETH", None) 29 | .await 30 | .unwrap(); 31 | 32 | info!("Update isolated margin response: {response:?}"); 33 | 34 | let user_state = info_client.user_state(address).await.unwrap(); 35 | info!("User state: {user_state:?}"); 36 | } 37 | -------------------------------------------------------------------------------- /src/bin/agent.rs: -------------------------------------------------------------------------------- 1 | use alloy::signers::local::PrivateKeySigner; 2 | use hyperliquid_rust_sdk::{BaseUrl, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient}; 3 | use log::info; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | env_logger::init(); 8 | // Key was randomly generated for testing and shouldn't be used with any real funds 9 | let wallet: PrivateKeySigner = 10 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 11 | .parse() 12 | .unwrap(); 13 | 14 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 15 | .await 16 | .unwrap(); 17 | 18 | /* 19 | Create a new wallet with the agent. 20 | This agent cannot transfer or withdraw funds, but can for example place orders. 21 | */ 22 | 23 | let (private_key, response) = exchange_client.approve_agent(None).await.unwrap(); 24 | info!("Agent creation response: {response:?}"); 25 | 26 | let wallet = PrivateKeySigner::from_bytes(&private_key).unwrap(); 27 | 28 | info!("Agent address: {:?}", wallet.address()); 29 | 30 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 31 | .await 32 | .unwrap(); 33 | 34 | let order = ClientOrderRequest { 35 | asset: "ETH".to_string(), 36 | is_buy: true, 37 | reduce_only: false, 38 | limit_px: 1795.0, 39 | sz: 0.01, 40 | cloid: None, 41 | order_type: ClientOrder::Limit(ClientLimit { 42 | tif: "Gtc".to_string(), 43 | }), 44 | }; 45 | 46 | let response = exchange_client.order(order, None).await.unwrap(); 47 | 48 | info!("Order placed: {response:?}"); 49 | } 50 | -------------------------------------------------------------------------------- /src/ws/message_types.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | use crate::ws::sub_structs::*; 4 | 5 | #[derive(Deserialize, Clone, Debug)] 6 | pub struct Trades { 7 | pub data: Vec, 8 | } 9 | 10 | #[derive(Deserialize, Clone, Debug)] 11 | pub struct L2Book { 12 | pub data: L2BookData, 13 | } 14 | 15 | #[derive(Deserialize, Clone, Debug)] 16 | pub struct AllMids { 17 | pub data: AllMidsData, 18 | } 19 | 20 | #[derive(Deserialize, Clone, Debug)] 21 | pub struct User { 22 | pub data: UserData, 23 | } 24 | 25 | #[derive(Deserialize, Clone, Debug)] 26 | pub struct UserFills { 27 | pub data: UserFillsData, 28 | } 29 | 30 | #[derive(Deserialize, Clone, Debug)] 31 | pub struct Candle { 32 | pub data: CandleData, 33 | } 34 | 35 | #[derive(Deserialize, Clone, Debug)] 36 | pub struct OrderUpdates { 37 | pub data: Vec, 38 | } 39 | 40 | #[derive(Deserialize, Clone, Debug)] 41 | pub struct UserFundings { 42 | pub data: UserFundingsData, 43 | } 44 | 45 | #[derive(Deserialize, Clone, Debug)] 46 | pub struct UserNonFundingLedgerUpdates { 47 | pub data: UserNonFundingLedgerUpdatesData, 48 | } 49 | 50 | #[derive(Deserialize, Clone, Debug)] 51 | pub struct Notification { 52 | pub data: NotificationData, 53 | } 54 | 55 | #[derive(Deserialize, Clone, Debug)] 56 | pub struct WebData2 { 57 | pub data: WebData2Data, 58 | } 59 | 60 | #[derive(Deserialize, Clone, Debug)] 61 | pub struct ActiveAssetCtx { 62 | pub data: ActiveAssetCtxData, 63 | } 64 | 65 | #[derive(Deserialize, Clone, Debug)] 66 | pub struct ActiveSpotAssetCtx { 67 | pub data: ActiveSpotAssetCtxData, 68 | } 69 | 70 | #[derive(Deserialize, Clone, Debug)] 71 | pub struct ActiveAssetData { 72 | pub data: ActiveAssetDataData, 73 | } 74 | 75 | #[derive(Deserialize, Clone, Debug)] 76 | pub struct Bbo { 77 | pub data: BboData, 78 | } 79 | -------------------------------------------------------------------------------- /src/bin/order_and_cancel_cloid.rs: -------------------------------------------------------------------------------- 1 | use std::{thread::sleep, time::Duration}; 2 | 3 | use alloy::signers::local::PrivateKeySigner; 4 | use hyperliquid_rust_sdk::{ 5 | BaseUrl, ClientCancelRequestCloid, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, 6 | }; 7 | use log::info; 8 | use uuid::Uuid; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | env_logger::init(); 13 | // Key was randomly generated for testing and shouldn't be used with any real funds 14 | let wallet: PrivateKeySigner = 15 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 16 | .parse() 17 | .unwrap(); 18 | 19 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 20 | .await 21 | .unwrap(); 22 | 23 | // Order and Cancel with cloid 24 | let cloid = Uuid::new_v4(); 25 | let order = ClientOrderRequest { 26 | asset: "ETH".to_string(), 27 | is_buy: true, 28 | reduce_only: false, 29 | limit_px: 1800.0, 30 | sz: 0.01, 31 | cloid: Some(cloid), 32 | order_type: ClientOrder::Limit(ClientLimit { 33 | tif: "Gtc".to_string(), 34 | }), 35 | }; 36 | 37 | let response = exchange_client.order(order, None).await.unwrap(); 38 | info!("Order placed: {response:?}"); 39 | 40 | // So you can see the order before it's cancelled 41 | sleep(Duration::from_secs(10)); 42 | 43 | let cancel = ClientCancelRequestCloid { 44 | asset: "ETH".to_string(), 45 | cloid, 46 | }; 47 | 48 | // This response will return an error if order was filled (since you can't cancel a filled order), otherwise it will cancel the order 49 | let response = exchange_client.cancel_by_cloid(cancel, None).await.unwrap(); 50 | info!("Order potentially cancelled: {response:?}"); 51 | } 52 | -------------------------------------------------------------------------------- /src/bin/order_and_cancel.rs: -------------------------------------------------------------------------------- 1 | use std::{thread::sleep, time::Duration}; 2 | 3 | use alloy::signers::local::PrivateKeySigner; 4 | use hyperliquid_rust_sdk::{ 5 | BaseUrl, ClientCancelRequest, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, 6 | ExchangeDataStatus, ExchangeResponseStatus, 7 | }; 8 | use log::info; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | env_logger::init(); 13 | // Key was randomly generated for testing and shouldn't be used with any real funds 14 | let wallet: PrivateKeySigner = 15 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 16 | .parse() 17 | .unwrap(); 18 | 19 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 20 | .await 21 | .unwrap(); 22 | 23 | let order = ClientOrderRequest { 24 | asset: "ETH".to_string(), 25 | is_buy: true, 26 | reduce_only: false, 27 | limit_px: 1800.0, 28 | sz: 0.01, 29 | cloid: None, 30 | order_type: ClientOrder::Limit(ClientLimit { 31 | tif: "Gtc".to_string(), 32 | }), 33 | }; 34 | 35 | let response = exchange_client.order(order, None).await.unwrap(); 36 | info!("Order placed: {response:?}"); 37 | 38 | let response = match response { 39 | ExchangeResponseStatus::Ok(exchange_response) => exchange_response, 40 | ExchangeResponseStatus::Err(e) => panic!("error with exchange response: {e}"), 41 | }; 42 | let status = response.data.unwrap().statuses[0].clone(); 43 | let oid = match status { 44 | ExchangeDataStatus::Filled(order) => order.oid, 45 | ExchangeDataStatus::Resting(order) => order.oid, 46 | _ => panic!("Error: {status:?}"), 47 | }; 48 | 49 | // So you can see the order before it's cancelled 50 | sleep(Duration::from_secs(10)); 51 | 52 | let cancel = ClientCancelRequest { 53 | asset: "ETH".to_string(), 54 | oid, 55 | }; 56 | 57 | // This response will return an error if order was filled (since you can't cancel a filled order), otherwise it will cancel the order 58 | let response = exchange_client.cancel(cancel, None).await.unwrap(); 59 | info!("Order potentially cancelled: {response:?}"); 60 | } 61 | -------------------------------------------------------------------------------- /src/bin/spot_order.rs: -------------------------------------------------------------------------------- 1 | use std::{thread::sleep, time::Duration}; 2 | 3 | use alloy::signers::local::PrivateKeySigner; 4 | use hyperliquid_rust_sdk::{ 5 | BaseUrl, ClientCancelRequest, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, 6 | ExchangeDataStatus, ExchangeResponseStatus, 7 | }; 8 | use log::info; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | env_logger::init(); 13 | // Key was randomly generated for testing and shouldn't be used with any real funds 14 | let wallet: PrivateKeySigner = 15 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 16 | .parse() 17 | .unwrap(); 18 | 19 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 20 | .await 21 | .unwrap(); 22 | 23 | let order = ClientOrderRequest { 24 | asset: "XYZTWO/USDC".to_string(), 25 | is_buy: true, 26 | reduce_only: false, 27 | limit_px: 0.00002378, 28 | sz: 1000000.0, 29 | cloid: None, 30 | order_type: ClientOrder::Limit(ClientLimit { 31 | tif: "Gtc".to_string(), 32 | }), 33 | }; 34 | 35 | let response = exchange_client.order(order, None).await.unwrap(); 36 | info!("Order placed: {response:?}"); 37 | 38 | let response = match response { 39 | ExchangeResponseStatus::Ok(exchange_response) => exchange_response, 40 | ExchangeResponseStatus::Err(e) => panic!("error with exchange response: {e}"), 41 | }; 42 | let status = response.data.unwrap().statuses[0].clone(); 43 | let oid = match status { 44 | ExchangeDataStatus::Filled(order) => order.oid, 45 | ExchangeDataStatus::Resting(order) => order.oid, 46 | _ => panic!("Error: {status:?}"), 47 | }; 48 | 49 | // So you can see the order before it's cancelled 50 | sleep(Duration::from_secs(10)); 51 | 52 | let cancel = ClientCancelRequest { 53 | asset: "HFUN/USDC".to_string(), 54 | oid, 55 | }; 56 | 57 | // This response will return an error if order was filled (since you can't cancel a filled order), otherwise it will cancel the order 58 | let response = exchange_client.cancel(cancel, None).await.unwrap(); 59 | info!("Order potentially cancelled: {response:?}"); 60 | } 61 | -------------------------------------------------------------------------------- /src/req.rs: -------------------------------------------------------------------------------- 1 | use reqwest::{Client, Response}; 2 | use serde::Deserialize; 3 | 4 | use crate::{prelude::*, BaseUrl, Error}; 5 | 6 | #[derive(Deserialize, Debug)] 7 | struct ErrorData { 8 | data: String, 9 | code: u16, 10 | msg: String, 11 | } 12 | 13 | #[derive(Debug)] 14 | pub struct HttpClient { 15 | pub client: Client, 16 | pub base_url: String, 17 | } 18 | 19 | async fn parse_response(response: Response) -> Result { 20 | let status_code = response.status().as_u16(); 21 | let text = response 22 | .text() 23 | .await 24 | .map_err(|e| Error::GenericRequest(e.to_string()))?; 25 | 26 | if status_code < 400 { 27 | return Ok(text); 28 | } 29 | let error_data = serde_json::from_str::(&text); 30 | if (400..500).contains(&status_code) { 31 | let client_error = match error_data { 32 | Ok(error_data) => Error::ClientRequest { 33 | status_code, 34 | error_code: Some(error_data.code), 35 | error_message: error_data.msg, 36 | error_data: Some(error_data.data), 37 | }, 38 | Err(err) => Error::ClientRequest { 39 | status_code, 40 | error_message: text, 41 | error_code: None, 42 | error_data: Some(err.to_string()), 43 | }, 44 | }; 45 | return Err(client_error); 46 | } 47 | 48 | Err(Error::ServerRequest { 49 | status_code, 50 | error_message: text, 51 | }) 52 | } 53 | 54 | impl HttpClient { 55 | pub async fn post(&self, url_path: &'static str, data: String) -> Result { 56 | let full_url = format!("{}{url_path}", self.base_url); 57 | let request = self 58 | .client 59 | .post(full_url) 60 | .header("Content-Type", "application/json") 61 | .body(data) 62 | .build() 63 | .map_err(|e| Error::GenericRequest(e.to_string()))?; 64 | let result = self 65 | .client 66 | .execute(request) 67 | .await 68 | .map_err(|e| Error::GenericRequest(e.to_string()))?; 69 | parse_response(result).await 70 | } 71 | 72 | pub fn is_mainnet(&self) -> bool { 73 | self.base_url == BaseUrl::Mainnet.get_url() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug, Clone)] 4 | pub enum Error { 5 | // TODO: turn some embedded types into errors instead of strings 6 | #[error("Client error: status code: {status_code}, error code: {error_code:?}, error message: {error_message}, error data: {error_data:?}")] 7 | ClientRequest { 8 | status_code: u16, 9 | error_code: Option, 10 | error_message: String, 11 | error_data: Option, 12 | }, 13 | #[error("Server error: status code: {status_code}, error message: {error_message}")] 14 | ServerRequest { 15 | status_code: u16, 16 | error_message: String, 17 | }, 18 | #[error("Generic request error: {0:?}")] 19 | GenericRequest(String), 20 | #[error("Chain type not allowed for this function")] 21 | ChainNotAllowed, 22 | #[error("Asset not found")] 23 | AssetNotFound, 24 | #[error("Error from Eip712 struct: {0:?}")] 25 | Eip712(String), 26 | #[error("Json parse error: {0:?}")] 27 | JsonParse(String), 28 | #[error("Generic parse error: {0:?}")] 29 | GenericParse(String), 30 | #[error("Wallet error: {0:?}")] 31 | Wallet(String), 32 | #[error("Websocket error: {0:?}")] 33 | Websocket(String), 34 | #[error("Subscription not found")] 35 | SubscriptionNotFound, 36 | #[error("WS manager not instantiated")] 37 | WsManagerNotFound, 38 | #[error("WS send error: {0:?}")] 39 | WsSend(String), 40 | #[error("Reader data not found")] 41 | ReaderDataNotFound, 42 | #[error("Reader error: {0:?}")] 43 | GenericReader(String), 44 | #[error("Reader text conversion error: {0:?}")] 45 | ReaderTextConversion(String), 46 | #[error("Order type not found")] 47 | OrderTypeNotFound, 48 | #[error("Issue with generating random data: {0:?}")] 49 | RandGen(String), 50 | #[error("Private key parse error: {0:?}")] 51 | PrivateKeyParse(String), 52 | #[error("Cannot subscribe to multiple user events")] 53 | UserEvents, 54 | #[error("Rmp parse error: {0:?}")] 55 | RmpParse(String), 56 | #[error("Invalid input number")] 57 | FloatStringParse, 58 | #[error("No cloid found in order request when expected")] 59 | NoCloid, 60 | #[error("ECDSA signature failed: {0:?}")] 61 | SignatureFailure(String), 62 | #[error("Vault address not found")] 63 | VaultAddressNotFound, 64 | } 65 | -------------------------------------------------------------------------------- /src/bin/order_and_schedule_cancel.rs: -------------------------------------------------------------------------------- 1 | use alloy::signers::local::PrivateKeySigner; 2 | use log::info; 3 | 4 | use hyperliquid_rust_sdk::{ 5 | BaseUrl, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, ExchangeDataStatus, 6 | ExchangeResponseStatus, 7 | }; 8 | use std::{thread::sleep, time::Duration}; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | env_logger::init(); 13 | // Key was randomly generated for testing and shouldn't be used with any real funds 14 | let wallet: PrivateKeySigner = 15 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 16 | .parse() 17 | .unwrap(); 18 | 19 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 20 | .await 21 | .unwrap(); 22 | 23 | info!("Testing Schedule Cancel Dead Man's Switch functionality..."); 24 | 25 | // First, place a test order that we can cancel later 26 | let order = ClientOrderRequest { 27 | asset: "ETH".to_string(), 28 | is_buy: true, 29 | reduce_only: false, 30 | limit_px: 100.0, 31 | sz: 0.01, 32 | cloid: None, 33 | order_type: ClientOrder::Limit(ClientLimit { 34 | tif: "Gtc".to_string(), 35 | }), 36 | }; 37 | 38 | let response = exchange_client.order(order, None).await.unwrap(); 39 | info!("Test order placed: {response:?}"); 40 | 41 | match response { 42 | ExchangeResponseStatus::Ok(exchange_response) => { 43 | let status = &exchange_response.data.unwrap().statuses[0]; 44 | match status { 45 | ExchangeDataStatus::Filled(_) => info!("Order was filled"), 46 | ExchangeDataStatus::Resting(_) => info!("Order is resting"), 47 | _ => info!("Order status: {status:?}"), 48 | } 49 | } 50 | ExchangeResponseStatus::Err(e) => { 51 | info!("Error placing order: {e}"); 52 | return; 53 | } 54 | } 55 | 56 | // Schedule a cancel operation 15 seconds in the future 57 | // Use chrono to for UTC timestamp 58 | let current_time = chrono::Utc::now().timestamp_millis() as u64; 59 | let cancel_time = current_time + 15000; // 15 seconds from now 60 | 61 | let response = exchange_client 62 | .schedule_cancel(Some(cancel_time), None) 63 | .await 64 | .unwrap(); 65 | info!("schedule_cancel response: {response:?}"); 66 | sleep(Duration::from_secs(20)); 67 | } 68 | -------------------------------------------------------------------------------- /src/bin/order_with_builder_and_cancel.rs: -------------------------------------------------------------------------------- 1 | use std::{thread::sleep, time::Duration}; 2 | 3 | use alloy::signers::local::PrivateKeySigner; 4 | use hyperliquid_rust_sdk::{ 5 | BaseUrl, BuilderInfo, ClientCancelRequest, ClientLimit, ClientOrder, ClientOrderRequest, 6 | ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, 7 | }; 8 | use log::info; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | env_logger::init(); 13 | // Key was randomly generated for testing and shouldn't be used with any real funds 14 | let wallet: PrivateKeySigner = 15 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 16 | .parse() 17 | .unwrap(); 18 | 19 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 20 | .await 21 | .unwrap(); 22 | 23 | let order = ClientOrderRequest { 24 | asset: "ETH".to_string(), 25 | is_buy: true, 26 | reduce_only: false, 27 | limit_px: 1800.0, 28 | sz: 0.01, 29 | cloid: None, 30 | order_type: ClientOrder::Limit(ClientLimit { 31 | tif: "Gtc".to_string(), 32 | }), 33 | }; 34 | 35 | let fee = 1u64; 36 | let builder = "0x1ab189B7801140900C711E458212F9c76F8dAC79"; 37 | 38 | let response = exchange_client 39 | .order_with_builder( 40 | order, 41 | None, 42 | BuilderInfo { 43 | builder: builder.to_string(), 44 | fee, 45 | }, 46 | ) 47 | .await 48 | .unwrap(); 49 | info!("Order placed: {response:?}"); 50 | 51 | let response = match response { 52 | ExchangeResponseStatus::Ok(exchange_response) => exchange_response, 53 | ExchangeResponseStatus::Err(e) => panic!("error with exchange response: {e}"), 54 | }; 55 | let status = response.data.unwrap().statuses[0].clone(); 56 | let oid = match status { 57 | ExchangeDataStatus::Filled(order) => order.oid, 58 | ExchangeDataStatus::Resting(order) => order.oid, 59 | _ => panic!("Error: {status:?}"), 60 | }; 61 | 62 | // So you can see the order before it's cancelled 63 | sleep(Duration::from_secs(10)); 64 | 65 | let cancel = ClientCancelRequest { 66 | asset: "ETH".to_string(), 67 | oid, 68 | }; 69 | 70 | // This response will return an error if order was filled (since you can't cancel a filled order), otherwise it will cancel the order 71 | let response = exchange_client.cancel(cancel, None).await.unwrap(); 72 | info!("Order potentially cancelled: {response:?}"); 73 | } 74 | -------------------------------------------------------------------------------- /src/bin/market_order_and_cancel.rs: -------------------------------------------------------------------------------- 1 | use std::{thread::sleep, time::Duration}; 2 | 3 | use alloy::signers::local::PrivateKeySigner; 4 | use hyperliquid_rust_sdk::{ 5 | BaseUrl, ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, MarketCloseParams, 6 | MarketOrderParams, 7 | }; 8 | use log::info; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | env_logger::init(); 13 | // Key was randomly generated for testing and shouldn't be used with any real funds 14 | let wallet: PrivateKeySigner = 15 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 16 | .parse() 17 | .unwrap(); 18 | 19 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 20 | .await 21 | .unwrap(); 22 | 23 | // Market open order 24 | let market_open_params = MarketOrderParams { 25 | asset: "ETH", 26 | is_buy: true, 27 | sz: 0.01, 28 | px: None, 29 | slippage: Some(0.01), // 1% slippage 30 | cloid: None, 31 | wallet: None, 32 | }; 33 | 34 | let response = exchange_client 35 | .market_open(market_open_params) 36 | .await 37 | .unwrap(); 38 | info!("Market open order placed: {response:?}"); 39 | 40 | let response = match response { 41 | ExchangeResponseStatus::Ok(exchange_response) => exchange_response, 42 | ExchangeResponseStatus::Err(e) => panic!("Error with exchange response: {e}"), 43 | }; 44 | let status = response.data.unwrap().statuses[0].clone(); 45 | match status { 46 | ExchangeDataStatus::Filled(order) => info!("Order filled: {order:?}"), 47 | ExchangeDataStatus::Resting(order) => info!("Order resting: {order:?}"), 48 | _ => panic!("Unexpected status: {status:?}"), 49 | }; 50 | 51 | // Wait for a while before closing the position 52 | sleep(Duration::from_secs(10)); 53 | 54 | // Market close order 55 | let market_close_params = MarketCloseParams { 56 | asset: "ETH", 57 | sz: None, // Close entire position 58 | px: None, 59 | slippage: Some(0.01), // 1% slippage 60 | cloid: None, 61 | wallet: None, 62 | }; 63 | 64 | let response = exchange_client 65 | .market_close(market_close_params) 66 | .await 67 | .unwrap(); 68 | info!("Market close order placed: {response:?}"); 69 | 70 | let response = match response { 71 | ExchangeResponseStatus::Ok(exchange_response) => exchange_response, 72 | ExchangeResponseStatus::Err(e) => panic!("Error with exchange response: {e}"), 73 | }; 74 | let status = response.data.unwrap().statuses[0].clone(); 75 | match status { 76 | ExchangeDataStatus::Filled(order) => info!("Close order filled: {order:?}"), 77 | ExchangeDataStatus::Resting(order) => info!("Close order resting: {order:?}"), 78 | _ => panic!("Unexpected status: {status:?}"), 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /src/bin/market_order_with_builder_and_cancel.rs: -------------------------------------------------------------------------------- 1 | use std::{thread::sleep, time::Duration}; 2 | 3 | use alloy::signers::local::PrivateKeySigner; 4 | use hyperliquid_rust_sdk::{ 5 | BaseUrl, BuilderInfo, ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, 6 | MarketCloseParams, MarketOrderParams, 7 | }; 8 | use log::info; 9 | 10 | #[tokio::main] 11 | async fn main() { 12 | env_logger::init(); 13 | // Key was randomly generated for testing and shouldn't be used with any real funds 14 | let wallet: PrivateKeySigner = 15 | "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 16 | .parse() 17 | .unwrap(); 18 | 19 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 20 | .await 21 | .unwrap(); 22 | 23 | // Market open order 24 | let market_open_params = MarketOrderParams { 25 | asset: "ETH", 26 | is_buy: true, 27 | sz: 0.01, 28 | px: None, 29 | slippage: Some(0.01), // 1% slippage 30 | cloid: None, 31 | wallet: None, 32 | }; 33 | 34 | let fee = 1; 35 | let builder = "0x1ab189B7801140900C711E458212F9c76F8dAC79"; 36 | 37 | let response = exchange_client 38 | .market_open_with_builder( 39 | market_open_params, 40 | BuilderInfo { 41 | builder: builder.to_string(), 42 | fee, 43 | }, 44 | ) 45 | .await 46 | .unwrap(); 47 | info!("Market open order placed: {response:?}"); 48 | 49 | let response = match response { 50 | ExchangeResponseStatus::Ok(exchange_response) => exchange_response, 51 | ExchangeResponseStatus::Err(e) => panic!("Error with exchange response: {e}"), 52 | }; 53 | let status = response.data.unwrap().statuses[0].clone(); 54 | match status { 55 | ExchangeDataStatus::Filled(order) => info!("Order filled: {order:?}"), 56 | ExchangeDataStatus::Resting(order) => info!("Order resting: {order:?}"), 57 | _ => panic!("Unexpected status: {status:?}"), 58 | }; 59 | 60 | // Wait for a while before closing the position 61 | sleep(Duration::from_secs(10)); 62 | 63 | // Market close order 64 | let market_close_params = MarketCloseParams { 65 | asset: "ETH", 66 | sz: None, // Close entire position 67 | px: None, 68 | slippage: Some(0.01), // 1% slippage 69 | cloid: None, 70 | wallet: None, 71 | }; 72 | 73 | let response = exchange_client 74 | .market_close(market_close_params) 75 | .await 76 | .unwrap(); 77 | info!("Market close order placed: {response:?}"); 78 | 79 | let response = match response { 80 | ExchangeResponseStatus::Ok(exchange_response) => exchange_response, 81 | ExchangeResponseStatus::Err(e) => panic!("Error with exchange response: {e}"), 82 | }; 83 | let status = response.data.unwrap().statuses[0].clone(); 84 | match status { 85 | ExchangeDataStatus::Filled(order) => info!("Close order filled: {order:?}"), 86 | ExchangeDataStatus::Resting(order) => info!("Close order resting: {order:?}"), 87 | _ => panic!("Unexpected status: {status:?}"), 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /src/meta.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use alloy::primitives::B128; 4 | use serde::Deserialize; 5 | 6 | #[derive(Deserialize, Debug, Clone)] 7 | pub struct Meta { 8 | pub universe: Vec, 9 | } 10 | 11 | #[derive(Deserialize, Debug, Clone)] 12 | pub struct SpotMeta { 13 | pub universe: Vec, 14 | pub tokens: Vec, 15 | } 16 | 17 | impl SpotMeta { 18 | pub fn add_pair_and_name_to_index_map( 19 | &self, 20 | mut coin_to_asset: HashMap, 21 | ) -> HashMap { 22 | let index_to_name: HashMap = self 23 | .tokens 24 | .iter() 25 | .map(|info| (info.index, info.name.as_str())) 26 | .collect(); 27 | 28 | for asset in self.universe.iter() { 29 | let spot_ind: u32 = 10000 + asset.index as u32; 30 | let name_to_ind = (asset.name.clone(), spot_ind); 31 | 32 | let Some(token_1_name) = index_to_name.get(&asset.tokens[0]) else { 33 | continue; 34 | }; 35 | 36 | let Some(token_2_name) = index_to_name.get(&asset.tokens[1]) else { 37 | continue; 38 | }; 39 | 40 | coin_to_asset.insert(format!("{token_1_name}/{token_2_name}"), spot_ind); 41 | coin_to_asset.insert(name_to_ind.0, name_to_ind.1); 42 | } 43 | 44 | coin_to_asset 45 | } 46 | } 47 | 48 | #[derive(Deserialize, Debug, Clone)] 49 | #[serde(untagged)] 50 | pub enum SpotMetaAndAssetCtxs { 51 | SpotMeta(SpotMeta), 52 | Context(Vec), 53 | } 54 | 55 | #[derive(Deserialize, Debug, Clone)] 56 | #[serde(untagged)] 57 | pub enum MetaAndAssetCtxs { 58 | Meta(Meta), 59 | Context(Vec), 60 | } 61 | 62 | #[derive(Deserialize, Debug, Clone)] 63 | #[serde(rename_all = "camelCase")] 64 | pub struct SpotAssetContext { 65 | pub day_ntl_vlm: String, 66 | pub mark_px: String, 67 | pub mid_px: Option, 68 | pub prev_day_px: String, 69 | pub circulating_supply: String, 70 | pub coin: String, 71 | } 72 | 73 | #[derive(Deserialize, Debug, Clone)] 74 | #[serde(rename_all = "camelCase")] 75 | pub struct AssetContext { 76 | pub day_ntl_vlm: String, 77 | pub funding: String, 78 | pub impact_pxs: Option>, 79 | pub mark_px: String, 80 | pub mid_px: Option, 81 | pub open_interest: String, 82 | pub oracle_px: String, 83 | pub premium: Option, 84 | pub prev_day_px: String, 85 | } 86 | 87 | #[derive(Deserialize, Debug, Clone)] 88 | #[serde(rename_all = "camelCase")] 89 | pub struct AssetMeta { 90 | pub name: String, 91 | pub sz_decimals: u32, 92 | pub max_leverage: usize, 93 | #[serde(default)] 94 | pub only_isolated: Option, 95 | } 96 | 97 | #[derive(Deserialize, Debug, Clone)] 98 | #[serde(rename_all = "camelCase")] 99 | pub struct SpotAssetMeta { 100 | pub tokens: [usize; 2], 101 | pub name: String, 102 | pub index: usize, 103 | pub is_canonical: bool, 104 | } 105 | 106 | #[derive(Debug, Deserialize, Clone)] 107 | #[serde(rename_all = "camelCase")] 108 | pub struct TokenInfo { 109 | pub name: String, 110 | pub sz_decimals: u8, 111 | pub wei_decimals: u8, 112 | pub index: usize, 113 | pub token_id: B128, 114 | pub is_canonical: bool, 115 | } 116 | -------------------------------------------------------------------------------- /src/exchange/order.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use alloy::signers::local::PrivateKeySigner; 4 | use serde::{Deserialize, Serialize}; 5 | use uuid::Uuid; 6 | 7 | use crate::{ 8 | errors::Error, 9 | helpers::{float_to_string_for_hashing, uuid_to_hex_string}, 10 | prelude::*, 11 | }; 12 | 13 | #[derive(Deserialize, Serialize, Clone, Debug)] 14 | pub struct Limit { 15 | pub tif: String, 16 | } 17 | 18 | #[derive(Deserialize, Serialize, Debug, Clone)] 19 | #[serde(rename_all = "camelCase")] 20 | pub struct Trigger { 21 | pub is_market: bool, 22 | pub trigger_px: String, 23 | pub tpsl: String, 24 | } 25 | 26 | #[derive(Deserialize, Serialize, Debug, Clone)] 27 | #[serde(rename_all = "camelCase")] 28 | pub enum Order { 29 | Limit(Limit), 30 | Trigger(Trigger), 31 | } 32 | 33 | #[derive(Deserialize, Serialize, Debug, Clone)] 34 | #[serde(rename_all = "camelCase")] 35 | pub struct OrderRequest { 36 | #[serde(rename = "a", alias = "asset")] 37 | pub asset: u32, 38 | #[serde(rename = "b", alias = "isBuy")] 39 | pub is_buy: bool, 40 | #[serde(rename = "p", alias = "limitPx")] 41 | pub limit_px: String, 42 | #[serde(rename = "s", alias = "sz")] 43 | pub sz: String, 44 | #[serde(rename = "r", alias = "reduceOnly", default)] 45 | pub reduce_only: bool, 46 | #[serde(rename = "t", alias = "orderType")] 47 | pub order_type: Order, 48 | #[serde(rename = "c", alias = "cloid", skip_serializing_if = "Option::is_none")] 49 | pub cloid: Option, 50 | } 51 | 52 | #[derive(Debug)] 53 | pub struct ClientLimit { 54 | pub tif: String, 55 | } 56 | 57 | #[derive(Debug)] 58 | pub struct ClientTrigger { 59 | pub is_market: bool, 60 | pub trigger_px: f64, 61 | pub tpsl: String, 62 | } 63 | 64 | #[derive(Debug)] 65 | pub struct MarketOrderParams<'a> { 66 | pub asset: &'a str, 67 | pub is_buy: bool, 68 | pub sz: f64, 69 | pub px: Option, 70 | pub slippage: Option, 71 | pub cloid: Option, 72 | pub wallet: Option<&'a PrivateKeySigner>, 73 | } 74 | 75 | #[derive(Debug)] 76 | pub struct MarketCloseParams<'a> { 77 | pub asset: &'a str, 78 | pub sz: Option, 79 | pub px: Option, 80 | pub slippage: Option, 81 | pub cloid: Option, 82 | pub wallet: Option<&'a PrivateKeySigner>, 83 | } 84 | 85 | #[derive(Debug)] 86 | pub enum ClientOrder { 87 | Limit(ClientLimit), 88 | Trigger(ClientTrigger), 89 | } 90 | 91 | #[derive(Debug)] 92 | pub struct ClientOrderRequest { 93 | pub asset: String, 94 | pub is_buy: bool, 95 | pub reduce_only: bool, 96 | pub limit_px: f64, 97 | pub sz: f64, 98 | pub cloid: Option, 99 | pub order_type: ClientOrder, 100 | } 101 | 102 | impl ClientOrderRequest { 103 | pub(crate) fn convert(self, coin_to_asset: &HashMap) -> Result { 104 | let order_type = match self.order_type { 105 | ClientOrder::Limit(limit) => Order::Limit(Limit { tif: limit.tif }), 106 | ClientOrder::Trigger(trigger) => Order::Trigger(Trigger { 107 | trigger_px: float_to_string_for_hashing(trigger.trigger_px), 108 | is_market: trigger.is_market, 109 | tpsl: trigger.tpsl, 110 | }), 111 | }; 112 | let &asset = coin_to_asset.get(&self.asset).ok_or(Error::AssetNotFound)?; 113 | 114 | let cloid = self.cloid.map(uuid_to_hex_string); 115 | 116 | Ok(OrderRequest { 117 | asset, 118 | is_buy: self.is_buy, 119 | reduce_only: self.reduce_only, 120 | limit_px: float_to_string_for_hashing(self.limit_px), 121 | sz: float_to_string_for_hashing(self.sz), 122 | order_type, 123 | cloid, 124 | }) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/signature/create_signature.rs: -------------------------------------------------------------------------------- 1 | use alloy::{ 2 | primitives::B256, 3 | signers::{local::PrivateKeySigner, Signature, SignerSync}, 4 | }; 5 | 6 | use crate::{eip712::Eip712, prelude::*, signature::agent::l1, Error}; 7 | 8 | pub(crate) fn sign_l1_action( 9 | wallet: &PrivateKeySigner, 10 | connection_id: B256, 11 | is_mainnet: bool, 12 | ) -> Result { 13 | let source = if is_mainnet { "a" } else { "b" }.to_string(); 14 | let payload = l1::Agent { 15 | source, 16 | connectionId: connection_id, 17 | }; 18 | sign_typed_data(&payload, wallet) 19 | } 20 | 21 | pub(crate) fn sign_typed_data( 22 | payload: &T, 23 | wallet: &PrivateKeySigner, 24 | ) -> Result { 25 | wallet 26 | .sign_hash_sync(&payload.eip712_signing_hash()) 27 | .map_err(|e| Error::SignatureFailure(e.to_string())) 28 | } 29 | 30 | #[cfg(test)] 31 | mod tests { 32 | use std::str::FromStr; 33 | 34 | use super::*; 35 | use crate::{UsdSend, Withdraw3}; 36 | 37 | fn get_wallet() -> Result { 38 | let priv_key = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e"; 39 | priv_key 40 | .parse::() 41 | .map_err(|e| Error::Wallet(e.to_string())) 42 | } 43 | 44 | #[test] 45 | fn test_sign_l1_action() -> Result<()> { 46 | let wallet = get_wallet()?; 47 | let connection_id = 48 | B256::from_str("0xde6c4037798a4434ca03cd05f00e3b803126221375cd1e7eaaaf041768be06eb") 49 | .map_err(|e| Error::GenericParse(e.to_string()))?; 50 | 51 | let expected_mainnet_sig = "0xfa8a41f6a3fa728206df80801a83bcbfbab08649cd34d9c0bfba7c7b2f99340f53a00226604567b98a1492803190d65a201d6805e5831b7044f17fd530aec7841c"; 52 | assert_eq!( 53 | sign_l1_action(&wallet, connection_id, true)?.to_string(), 54 | expected_mainnet_sig 55 | ); 56 | let expected_testnet_sig = "0x1713c0fc661b792a50e8ffdd59b637b1ed172d9a3aa4d801d9d88646710fb74b33959f4d075a7ccbec9f2374a6da21ffa4448d58d0413a0d335775f680a881431c"; 57 | assert_eq!( 58 | sign_l1_action(&wallet, connection_id, false)?.to_string(), 59 | expected_testnet_sig 60 | ); 61 | Ok(()) 62 | } 63 | 64 | #[test] 65 | fn test_sign_usd_transfer_action() -> Result<()> { 66 | let wallet = get_wallet()?; 67 | 68 | let usd_send = UsdSend { 69 | signature_chain_id: 421614, 70 | hyperliquid_chain: "Testnet".to_string(), 71 | destination: "0x0D1d9635D0640821d15e323ac8AdADfA9c111414".to_string(), 72 | amount: "1".to_string(), 73 | time: 1690393044548, 74 | }; 75 | 76 | let expected_sig = "0x214d507bbdaebba52fa60928f904a8b2df73673e3baba6133d66fe846c7ef70451e82453a6d8db124e7ed6e60fa00d4b7c46e4d96cb2bd61fd81b6e8953cc9d21b"; 77 | assert_eq!( 78 | sign_typed_data(&usd_send, &wallet)?.to_string(), 79 | expected_sig 80 | ); 81 | Ok(()) 82 | } 83 | 84 | #[test] 85 | fn test_sign_withdraw_from_bridge_action() -> Result<()> { 86 | let wallet = get_wallet()?; 87 | 88 | let usd_send = Withdraw3 { 89 | signature_chain_id: 421614, 90 | hyperliquid_chain: "Testnet".to_string(), 91 | destination: "0x0D1d9635D0640821d15e323ac8AdADfA9c111414".to_string(), 92 | amount: "1".to_string(), 93 | time: 1690393044548, 94 | }; 95 | 96 | let expected_sig = "0xb3172e33d2262dac2b4cb135ce3c167fda55dafa6c62213564ab728b9f9ba76b769a938e9f6d603dae7154c83bf5a4c3ebab81779dc2db25463a3ed663c82ae41c"; 97 | assert_eq!( 98 | sign_typed_data(&usd_send, &wallet)?.to_string(), 99 | expected_sig 100 | ); 101 | Ok(()) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/helpers.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic::{AtomicU64, Ordering}; 2 | 3 | use chrono::prelude::Utc; 4 | use lazy_static::lazy_static; 5 | use log::info; 6 | use uuid::Uuid; 7 | 8 | use crate::consts::*; 9 | 10 | fn now_timestamp_ms() -> u64 { 11 | let now = Utc::now(); 12 | now.timestamp_millis() as u64 13 | } 14 | 15 | pub(crate) fn next_nonce() -> u64 { 16 | let nonce = CUR_NONCE.fetch_add(1, Ordering::Relaxed); 17 | let now_ms = now_timestamp_ms(); 18 | if nonce > now_ms + 1000 { 19 | info!("nonce progressed too far ahead {nonce} {now_ms}"); 20 | } 21 | // more than 300 seconds behind 22 | if nonce + 300000 < now_ms { 23 | CUR_NONCE.fetch_max(now_ms + 1, Ordering::Relaxed); 24 | return now_ms; 25 | } 26 | nonce 27 | } 28 | 29 | pub(crate) const WIRE_DECIMALS: u8 = 8; 30 | 31 | pub(crate) fn float_to_string_for_hashing(x: f64) -> String { 32 | let mut x = format!("{:.*}", WIRE_DECIMALS.into(), x); 33 | while x.ends_with('0') { 34 | x.pop(); 35 | } 36 | if x.ends_with('.') { 37 | x.pop(); 38 | } 39 | if x == "-0" { 40 | "0".to_string() 41 | } else { 42 | x 43 | } 44 | } 45 | 46 | pub(crate) fn uuid_to_hex_string(uuid: Uuid) -> String { 47 | let hex_string = uuid 48 | .as_bytes() 49 | .iter() 50 | .map(|byte| format!("{byte:02x}")) 51 | .collect::>() 52 | .join(""); 53 | format!("0x{hex_string}") 54 | } 55 | 56 | pub fn truncate_float(float: f64, decimals: u32, round_up: bool) -> f64 { 57 | let pow10 = 10i64.pow(decimals) as f64; 58 | let mut float = (float * pow10) as u64; 59 | if round_up { 60 | float += 1; 61 | } 62 | float as f64 / pow10 63 | } 64 | 65 | pub fn bps_diff(x: f64, y: f64) -> u16 { 66 | if x.abs() < EPSILON { 67 | INF_BPS 68 | } else { 69 | (((y - x).abs() / (x)) * 10_000.0) as u16 70 | } 71 | } 72 | 73 | #[derive(Copy, Clone)] 74 | pub enum BaseUrl { 75 | Localhost, 76 | Testnet, 77 | Mainnet, 78 | } 79 | 80 | impl BaseUrl { 81 | pub(crate) fn get_url(&self) -> String { 82 | match self { 83 | BaseUrl::Localhost => LOCAL_API_URL.to_string(), 84 | BaseUrl::Mainnet => MAINNET_API_URL.to_string(), 85 | BaseUrl::Testnet => TESTNET_API_URL.to_string(), 86 | } 87 | } 88 | } 89 | 90 | lazy_static! { 91 | static ref CUR_NONCE: AtomicU64 = AtomicU64::new(now_timestamp_ms()); 92 | } 93 | 94 | #[cfg(test)] 95 | mod tests { 96 | use super::*; 97 | 98 | #[test] 99 | fn float_to_string_for_hashing_test() { 100 | assert_eq!(float_to_string_for_hashing(0.), "0".to_string()); 101 | assert_eq!(float_to_string_for_hashing(-0.), "0".to_string()); 102 | assert_eq!(float_to_string_for_hashing(-0.0000), "0".to_string()); 103 | assert_eq!( 104 | float_to_string_for_hashing(0.00076000), 105 | "0.00076".to_string() 106 | ); 107 | assert_eq!( 108 | float_to_string_for_hashing(0.00000001), 109 | "0.00000001".to_string() 110 | ); 111 | assert_eq!( 112 | float_to_string_for_hashing(0.12345678), 113 | "0.12345678".to_string() 114 | ); 115 | assert_eq!( 116 | float_to_string_for_hashing(87654321.12345678), 117 | "87654321.12345678".to_string() 118 | ); 119 | assert_eq!( 120 | float_to_string_for_hashing(987654321.00000000), 121 | "987654321".to_string() 122 | ); 123 | assert_eq!( 124 | float_to_string_for_hashing(87654321.1234), 125 | "87654321.1234".to_string() 126 | ); 127 | assert_eq!(float_to_string_for_hashing(0.000760), "0.00076".to_string()); 128 | assert_eq!(float_to_string_for_hashing(0.00076), "0.00076".to_string()); 129 | assert_eq!( 130 | float_to_string_for_hashing(987654321.0), 131 | "987654321".to_string() 132 | ); 133 | assert_eq!( 134 | float_to_string_for_hashing(987654321.), 135 | "987654321".to_string() 136 | ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/info/response_structs.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | use alloy::primitives::Address; 4 | 5 | use crate::{ 6 | info::{AssetPosition, Level, MarginSummary}, 7 | DailyUserVlm, Delta, FeeSchedule, Leverage, OrderInfo, Referrer, ReferrerState, 8 | UserTokenBalance, 9 | }; 10 | 11 | #[derive(Deserialize, Debug)] 12 | #[serde(rename_all = "camelCase")] 13 | pub struct UserStateResponse { 14 | pub asset_positions: Vec, 15 | pub cross_margin_summary: MarginSummary, 16 | pub margin_summary: MarginSummary, 17 | pub withdrawable: String, 18 | } 19 | 20 | #[derive(Deserialize, Debug)] 21 | pub struct UserTokenBalanceResponse { 22 | pub balances: Vec, 23 | } 24 | 25 | #[derive(Deserialize, Debug)] 26 | #[serde(rename_all = "camelCase")] 27 | pub struct UserFeesResponse { 28 | pub active_referral_discount: String, 29 | pub daily_user_vlm: Vec, 30 | pub fee_schedule: FeeSchedule, 31 | pub user_add_rate: String, 32 | pub user_cross_rate: String, 33 | } 34 | 35 | #[derive(serde::Deserialize, Debug)] 36 | #[serde(rename_all = "camelCase")] 37 | pub struct OpenOrdersResponse { 38 | pub coin: String, 39 | pub limit_px: String, 40 | pub oid: u64, 41 | pub side: String, 42 | pub sz: String, 43 | pub timestamp: u64, 44 | pub cloid: Option, 45 | } 46 | 47 | #[derive(serde::Deserialize, Debug)] 48 | #[serde(rename_all = "camelCase")] 49 | pub struct UserFillsResponse { 50 | pub closed_pnl: String, 51 | pub coin: String, 52 | pub crossed: bool, 53 | pub dir: String, 54 | pub hash: String, 55 | pub oid: u64, 56 | pub px: String, 57 | pub side: String, 58 | pub start_position: String, 59 | pub sz: String, 60 | pub time: u64, 61 | pub fee: String, 62 | pub tid: u64, 63 | pub fee_token: String, 64 | pub twap_id: Option, 65 | } 66 | 67 | #[derive(serde::Deserialize, Debug)] 68 | #[serde(rename_all = "camelCase")] 69 | pub struct FundingHistoryResponse { 70 | pub coin: String, 71 | pub funding_rate: String, 72 | pub premium: String, 73 | pub time: u64, 74 | } 75 | 76 | #[derive(Deserialize, Debug)] 77 | pub struct UserFundingResponse { 78 | pub time: u64, 79 | pub hash: String, 80 | pub delta: Delta, 81 | } 82 | 83 | #[derive(serde::Deserialize, Debug)] 84 | #[serde(rename_all = "camelCase")] 85 | pub struct L2SnapshotResponse { 86 | pub coin: String, 87 | pub levels: Vec>, 88 | pub time: u64, 89 | } 90 | 91 | #[derive(serde::Deserialize, Debug)] 92 | #[serde(rename_all = "camelCase")] 93 | pub struct RecentTradesResponse { 94 | pub coin: String, 95 | pub side: String, 96 | pub px: String, 97 | pub sz: String, 98 | pub time: u64, 99 | pub hash: String, 100 | } 101 | 102 | #[derive(serde::Deserialize, Debug)] 103 | pub struct CandlesSnapshotResponse { 104 | #[serde(rename = "t")] 105 | pub time_open: u64, 106 | #[serde(rename = "T")] 107 | pub time_close: u64, 108 | #[serde(rename = "s")] 109 | pub coin: String, 110 | #[serde(rename = "i")] 111 | pub candle_interval: String, 112 | #[serde(rename = "o")] 113 | pub open: String, 114 | #[serde(rename = "c")] 115 | pub close: String, 116 | #[serde(rename = "h")] 117 | pub high: String, 118 | #[serde(rename = "l")] 119 | pub low: String, 120 | #[serde(rename = "v")] 121 | pub vlm: String, 122 | #[serde(rename = "n")] 123 | pub num_trades: u64, 124 | } 125 | 126 | #[derive(Deserialize, Debug)] 127 | pub struct OrderStatusResponse { 128 | pub status: String, 129 | /// `None` if the order is not found 130 | #[serde(default)] 131 | pub order: Option, 132 | } 133 | 134 | #[derive(Deserialize, Debug)] 135 | #[serde(rename_all = "camelCase")] 136 | pub struct ReferralResponse { 137 | pub referred_by: Option, 138 | pub cum_vlm: String, 139 | pub unclaimed_rewards: String, 140 | pub claimed_rewards: String, 141 | pub referrer_state: ReferrerState, 142 | } 143 | 144 | #[derive(Deserialize, Debug)] 145 | #[serde(rename_all = "camelCase")] 146 | pub struct ActiveAssetDataResponse { 147 | pub user: Address, 148 | pub coin: String, 149 | pub leverage: Leverage, 150 | pub max_trade_szs: Vec, 151 | pub available_to_trade: Vec, 152 | pub mark_px: String, 153 | } 154 | -------------------------------------------------------------------------------- /src/info/sub_structs.rs: -------------------------------------------------------------------------------- 1 | use alloy::primitives::Address; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Deserialize, Serialize, Debug, Clone)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct Leverage { 7 | #[serde(rename = "type")] 8 | pub type_string: String, 9 | pub value: u32, 10 | pub raw_usd: Option, 11 | } 12 | 13 | #[derive(Deserialize, Debug)] 14 | #[serde(rename_all = "camelCase")] 15 | pub struct CumulativeFunding { 16 | pub all_time: String, 17 | pub since_open: String, 18 | pub since_change: String, 19 | } 20 | 21 | #[derive(Deserialize, Debug)] 22 | #[serde(rename_all = "camelCase")] 23 | pub struct PositionData { 24 | pub coin: String, 25 | pub entry_px: Option, 26 | pub leverage: Leverage, 27 | pub liquidation_px: Option, 28 | pub margin_used: String, 29 | pub position_value: String, 30 | pub return_on_equity: String, 31 | pub szi: String, 32 | pub unrealized_pnl: String, 33 | pub max_leverage: u32, 34 | pub cum_funding: CumulativeFunding, 35 | } 36 | 37 | #[derive(Deserialize, Debug)] 38 | pub struct AssetPosition { 39 | pub position: PositionData, 40 | #[serde(rename = "type")] 41 | pub type_string: String, 42 | } 43 | 44 | #[derive(Deserialize, Debug)] 45 | #[serde(rename_all = "camelCase")] 46 | pub struct MarginSummary { 47 | pub account_value: String, 48 | pub total_margin_used: String, 49 | pub total_ntl_pos: String, 50 | pub total_raw_usd: String, 51 | } 52 | 53 | #[derive(Deserialize, Debug)] 54 | #[serde(rename_all = "camelCase")] 55 | pub struct Level { 56 | pub n: u64, 57 | pub px: String, 58 | pub sz: String, 59 | } 60 | 61 | #[derive(Deserialize, Debug)] 62 | #[serde(rename_all = "camelCase")] 63 | pub struct Delta { 64 | #[serde(rename = "type")] 65 | pub type_string: String, 66 | pub coin: String, 67 | pub usdc: String, 68 | pub szi: String, 69 | pub funding_rate: String, 70 | } 71 | 72 | #[derive(Deserialize, Debug)] 73 | #[serde(rename_all = "camelCase")] 74 | pub struct DailyUserVlm { 75 | pub date: String, 76 | pub exchange: String, 77 | pub user_add: String, 78 | pub user_cross: String, 79 | } 80 | 81 | #[derive(Deserialize, Debug)] 82 | #[serde(rename_all = "camelCase")] 83 | pub struct FeeSchedule { 84 | pub add: String, 85 | pub cross: String, 86 | pub referral_discount: String, 87 | pub tiers: Tiers, 88 | } 89 | 90 | #[derive(Deserialize, Debug)] 91 | pub struct Tiers { 92 | pub mm: Vec, 93 | pub vip: Vec, 94 | } 95 | 96 | #[derive(Deserialize, Debug)] 97 | #[serde(rename_all = "camelCase")] 98 | pub struct Mm { 99 | pub add: String, 100 | pub maker_fraction_cutoff: String, 101 | } 102 | 103 | #[derive(Deserialize, Debug)] 104 | #[serde(rename_all = "camelCase")] 105 | pub struct Vip { 106 | pub add: String, 107 | pub cross: String, 108 | pub ntl_cutoff: String, 109 | } 110 | 111 | #[derive(Deserialize, Debug)] 112 | #[serde(rename_all = "camelCase")] 113 | pub struct UserTokenBalance { 114 | pub coin: String, 115 | pub hold: String, 116 | pub total: String, 117 | pub entry_ntl: String, 118 | } 119 | 120 | #[derive(Deserialize, Clone, Debug)] 121 | #[serde(rename_all = "camelCase")] 122 | pub struct OrderInfo { 123 | pub order: BasicOrderInfo, 124 | pub status: String, 125 | pub status_timestamp: u64, 126 | } 127 | 128 | #[derive(Deserialize, Clone, Debug)] 129 | #[serde(rename_all = "camelCase")] 130 | pub struct BasicOrderInfo { 131 | pub coin: String, 132 | pub side: String, 133 | pub limit_px: String, 134 | pub sz: String, 135 | pub oid: u64, 136 | pub timestamp: u64, 137 | pub trigger_condition: String, 138 | pub is_trigger: bool, 139 | pub trigger_px: String, 140 | pub is_position_tpsl: bool, 141 | pub reduce_only: bool, 142 | pub order_type: String, 143 | pub orig_sz: String, 144 | pub tif: Option, 145 | pub cloid: Option, 146 | } 147 | 148 | #[derive(Deserialize, Debug)] 149 | #[serde(rename_all = "camelCase")] 150 | pub struct Referrer { 151 | pub referrer: Address, 152 | pub code: String, 153 | } 154 | 155 | #[derive(Deserialize, Debug)] 156 | #[serde(rename_all = "camelCase")] 157 | pub struct ReferrerState { 158 | pub stage: String, 159 | pub data: ReferrerData, 160 | } 161 | 162 | #[derive(Deserialize, Debug)] 163 | #[serde(rename_all = "camelCase")] 164 | pub struct ReferrerData { 165 | pub required: String, 166 | } 167 | -------------------------------------------------------------------------------- /src/bin/info.rs: -------------------------------------------------------------------------------- 1 | use alloy::primitives::Address; 2 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient}; 3 | use log::info; 4 | 5 | const ADDRESS: &str = "0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"; 6 | 7 | #[tokio::main] 8 | async fn main() { 9 | env_logger::init(); 10 | let info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 11 | open_orders_example(&info_client).await; 12 | user_state_example(&info_client).await; 13 | user_states_example(&info_client).await; 14 | recent_trades(&info_client).await; 15 | meta_example(&info_client).await; 16 | meta_and_asset_contexts_example(&info_client).await; 17 | active_asset_data_example(&info_client).await; 18 | all_mids_example(&info_client).await; 19 | user_fills_example(&info_client).await; 20 | funding_history_example(&info_client).await; 21 | l2_snapshot_example(&info_client).await; 22 | candles_snapshot_example(&info_client).await; 23 | user_token_balances_example(&info_client).await; 24 | user_fees_example(&info_client).await; 25 | user_funding_example(&info_client).await; 26 | spot_meta_example(&info_client).await; 27 | spot_meta_and_asset_contexts_example(&info_client).await; 28 | query_order_by_oid_example(&info_client).await; 29 | query_referral_state_example(&info_client).await; 30 | historical_orders_example(&info_client).await; 31 | } 32 | 33 | fn address() -> Address { 34 | ADDRESS.to_string().parse().unwrap() 35 | } 36 | 37 | async fn open_orders_example(info_client: &InfoClient) { 38 | let user = address(); 39 | 40 | info!( 41 | "Open order data for {user}: {:?}", 42 | info_client.open_orders(user).await.unwrap() 43 | ); 44 | } 45 | 46 | async fn user_state_example(info_client: &InfoClient) { 47 | let user = address(); 48 | 49 | info!( 50 | "User state data for {user}: {:?}", 51 | info_client.user_state(user).await.unwrap() 52 | ); 53 | } 54 | 55 | async fn user_states_example(info_client: &InfoClient) { 56 | let user = address(); 57 | 58 | info!( 59 | "User state data for {user}: {:?}", 60 | info_client.user_states(vec![user]).await.unwrap() 61 | ); 62 | } 63 | 64 | async fn user_token_balances_example(info_client: &InfoClient) { 65 | let user = address(); 66 | 67 | info!( 68 | "User token balances data for {user}: {:?}", 69 | info_client.user_token_balances(user).await.unwrap() 70 | ); 71 | } 72 | 73 | async fn user_fees_example(info_client: &InfoClient) { 74 | let user = address(); 75 | 76 | info!( 77 | "User fees data for {user}: {:?}", 78 | info_client.user_fees(user).await.unwrap() 79 | ); 80 | } 81 | 82 | async fn recent_trades(info_client: &InfoClient) { 83 | let coin = "ETH"; 84 | 85 | info!( 86 | "Recent trades for {coin}: {:?}", 87 | info_client.recent_trades(coin.to_string()).await.unwrap() 88 | ); 89 | } 90 | 91 | async fn meta_example(info_client: &InfoClient) { 92 | info!("Meta: {:?}", info_client.meta().await.unwrap()); 93 | } 94 | 95 | async fn meta_and_asset_contexts_example(info_client: &InfoClient) { 96 | info!( 97 | "Meta and asset contexts: {:?}", 98 | info_client.meta_and_asset_contexts().await.unwrap() 99 | ); 100 | } 101 | 102 | async fn all_mids_example(info_client: &InfoClient) { 103 | info!("All mids: {:?}", info_client.all_mids().await.unwrap()); 104 | } 105 | 106 | async fn user_fills_example(info_client: &InfoClient) { 107 | let user = address(); 108 | 109 | info!( 110 | "User fills data for {user}: {:?}", 111 | info_client.user_fills(user).await.unwrap() 112 | ); 113 | } 114 | 115 | async fn funding_history_example(info_client: &InfoClient) { 116 | let coin = "ETH"; 117 | 118 | let start_timestamp = 1690540602225; 119 | let end_timestamp = 1690569402225; 120 | info!( 121 | "Funding data history for {coin} between timestamps {start_timestamp} and {end_timestamp}: {:?}", 122 | info_client.funding_history(coin.to_string(), start_timestamp, Some(end_timestamp)).await.unwrap() 123 | ); 124 | } 125 | 126 | async fn l2_snapshot_example(info_client: &InfoClient) { 127 | let coin = "ETH"; 128 | 129 | info!( 130 | "L2 snapshot data for {coin}: {:?}", 131 | info_client.l2_snapshot(coin.to_string()).await.unwrap() 132 | ); 133 | } 134 | 135 | async fn candles_snapshot_example(info_client: &InfoClient) { 136 | let coin = "ETH"; 137 | let start_timestamp = 1690540602225; 138 | let end_timestamp = 1690569402225; 139 | let interval = "1h"; 140 | 141 | info!( 142 | "Candles snapshot data for {coin} between timestamps {start_timestamp} and {end_timestamp} with interval {interval}: {:?}", 143 | info_client 144 | .candles_snapshot(coin.to_string(), interval.to_string(), start_timestamp, end_timestamp) 145 | .await 146 | .unwrap() 147 | ); 148 | } 149 | 150 | async fn user_funding_example(info_client: &InfoClient) { 151 | let user = address(); 152 | let start_timestamp = 1690540602225; 153 | let end_timestamp = 1690569402225; 154 | info!( 155 | "Funding data history for {user} between timestamps {start_timestamp} and {end_timestamp}: {:?}", 156 | info_client.user_funding_history(user, start_timestamp, Some(end_timestamp)).await.unwrap() 157 | ); 158 | } 159 | 160 | async fn spot_meta_example(info_client: &InfoClient) { 161 | info!("SpotMeta: {:?}", info_client.spot_meta().await.unwrap()); 162 | } 163 | 164 | async fn spot_meta_and_asset_contexts_example(info_client: &InfoClient) { 165 | info!( 166 | "SpotMetaAndAssetContexts: {:?}", 167 | info_client.spot_meta_and_asset_contexts().await.unwrap() 168 | ); 169 | } 170 | 171 | async fn query_order_by_oid_example(info_client: &InfoClient) { 172 | let user = address(); 173 | let oid = 26342632321; 174 | info!( 175 | "Order status for {user} for oid {oid}: {:?}", 176 | info_client.query_order_by_oid(user, oid).await.unwrap() 177 | ); 178 | } 179 | 180 | async fn query_referral_state_example(info_client: &InfoClient) { 181 | let user = address(); 182 | info!( 183 | "Referral state for {user}: {:?}", 184 | info_client.query_referral_state(user).await.unwrap() 185 | ); 186 | } 187 | 188 | async fn active_asset_data_example(info_client: &InfoClient) { 189 | let user = address(); 190 | let coin = "ETH"; 191 | 192 | info!( 193 | "Active asset data for {user} and coin {coin}: {:?}", 194 | info_client 195 | .active_asset_data(user, coin.to_string()) 196 | .await 197 | .unwrap() 198 | ); 199 | } 200 | 201 | async fn historical_orders_example(info_client: &InfoClient) { 202 | let user = address(); 203 | info!( 204 | "Historical orders for {user}: {:?}", 205 | info_client.historical_orders(user).await.unwrap() 206 | ); 207 | } 208 | -------------------------------------------------------------------------------- /src/exchange/actions.rs: -------------------------------------------------------------------------------- 1 | use alloy::{ 2 | dyn_abi::Eip712Domain, 3 | primitives::{keccak256, Address, B256}, 4 | sol_types::{eip712_domain, SolValue}, 5 | }; 6 | use serde::{Deserialize, Serialize, Serializer}; 7 | 8 | use super::{cancel::CancelRequestCloid, BuilderInfo}; 9 | use crate::{ 10 | eip712::Eip712, 11 | exchange::{cancel::CancelRequest, modify::ModifyRequest, order::OrderRequest}, 12 | }; 13 | 14 | fn eip_712_domain(chain_id: u64) -> Eip712Domain { 15 | eip712_domain! { 16 | name: "HyperliquidSignTransaction", 17 | version: "1", 18 | chain_id: chain_id, 19 | verifying_contract: Address::ZERO, 20 | } 21 | } 22 | 23 | fn serialize_hex(val: &u64, s: S) -> Result 24 | where 25 | S: Serializer, 26 | { 27 | s.serialize_str(&format!("0x{val:x}")) 28 | } 29 | 30 | #[derive(Serialize, Deserialize, Debug, Clone)] 31 | #[serde(rename_all = "camelCase")] 32 | pub struct UsdSend { 33 | #[serde(serialize_with = "serialize_hex")] 34 | pub signature_chain_id: u64, 35 | pub hyperliquid_chain: String, 36 | pub destination: String, 37 | pub amount: String, 38 | pub time: u64, 39 | } 40 | 41 | impl Eip712 for UsdSend { 42 | fn domain(&self) -> Eip712Domain { 43 | eip_712_domain(self.signature_chain_id) 44 | } 45 | 46 | fn struct_hash(&self) -> B256 { 47 | let items = ( 48 | keccak256("HyperliquidTransaction:UsdSend(string hyperliquidChain,string destination,string amount,uint64 time)"), 49 | keccak256(&self.hyperliquid_chain), 50 | keccak256(&self.destination), 51 | keccak256(&self.amount), 52 | &self.time 53 | ); 54 | keccak256(items.abi_encode()) 55 | } 56 | } 57 | 58 | #[derive(Serialize, Deserialize, Debug, Clone)] 59 | #[serde(rename_all = "camelCase")] 60 | pub struct UpdateLeverage { 61 | pub asset: u32, 62 | pub is_cross: bool, 63 | pub leverage: u32, 64 | } 65 | 66 | #[derive(Serialize, Deserialize, Debug, Clone)] 67 | #[serde(rename_all = "camelCase")] 68 | pub struct UpdateIsolatedMargin { 69 | pub asset: u32, 70 | pub is_buy: bool, 71 | pub ntli: i64, 72 | } 73 | 74 | #[derive(Serialize, Deserialize, Debug, Clone)] 75 | #[serde(rename_all = "camelCase")] 76 | pub struct BulkOrder { 77 | pub orders: Vec, 78 | pub grouping: String, 79 | #[serde(default, skip_serializing_if = "Option::is_none")] 80 | pub builder: Option, 81 | } 82 | 83 | #[derive(Serialize, Deserialize, Debug, Clone)] 84 | #[serde(rename_all = "camelCase")] 85 | pub struct BulkCancel { 86 | pub cancels: Vec, 87 | } 88 | 89 | #[derive(Serialize, Deserialize, Debug, Clone)] 90 | #[serde(rename_all = "camelCase")] 91 | pub struct BulkModify { 92 | pub modifies: Vec, 93 | } 94 | 95 | #[derive(Serialize, Deserialize, Debug, Clone)] 96 | #[serde(rename_all = "camelCase")] 97 | pub struct BulkCancelCloid { 98 | pub cancels: Vec, 99 | } 100 | 101 | #[derive(Serialize, Deserialize, Debug, Clone)] 102 | #[serde(rename_all = "camelCase")] 103 | pub struct ApproveAgent { 104 | #[serde(serialize_with = "serialize_hex")] 105 | pub signature_chain_id: u64, 106 | pub hyperliquid_chain: String, 107 | pub agent_address: Address, 108 | pub agent_name: Option, 109 | pub nonce: u64, 110 | } 111 | 112 | impl Eip712 for ApproveAgent { 113 | fn domain(&self) -> Eip712Domain { 114 | eip_712_domain(self.signature_chain_id) 115 | } 116 | 117 | fn struct_hash(&self) -> B256 { 118 | let items = ( 119 | keccak256("HyperliquidTransaction:ApproveAgent(string hyperliquidChain,address agentAddress,string agentName,uint64 nonce)"), 120 | keccak256(&self.hyperliquid_chain), 121 | &self.agent_address, 122 | keccak256(self.agent_name.as_deref().unwrap_or("")), 123 | &self.nonce 124 | ); 125 | keccak256(items.abi_encode()) 126 | } 127 | } 128 | 129 | #[derive(Serialize, Deserialize, Debug, Clone)] 130 | #[serde(rename_all = "camelCase")] 131 | pub struct Withdraw3 { 132 | #[serde(serialize_with = "serialize_hex")] 133 | pub signature_chain_id: u64, 134 | pub hyperliquid_chain: String, 135 | pub destination: String, 136 | pub amount: String, 137 | pub time: u64, 138 | } 139 | 140 | impl Eip712 for Withdraw3 { 141 | fn domain(&self) -> Eip712Domain { 142 | eip_712_domain(self.signature_chain_id) 143 | } 144 | 145 | fn struct_hash(&self) -> B256 { 146 | let items = ( 147 | keccak256("HyperliquidTransaction:Withdraw(string hyperliquidChain,string destination,string amount,uint64 time)"), 148 | keccak256(&self.hyperliquid_chain), 149 | keccak256(&self.destination), 150 | keccak256(&self.amount), 151 | &self.time, 152 | ); 153 | keccak256(items.abi_encode()) 154 | } 155 | } 156 | 157 | #[derive(Serialize, Deserialize, Debug, Clone)] 158 | #[serde(rename_all = "camelCase")] 159 | pub struct SpotSend { 160 | #[serde(serialize_with = "serialize_hex")] 161 | pub signature_chain_id: u64, 162 | pub hyperliquid_chain: String, 163 | pub destination: String, 164 | pub token: String, 165 | pub amount: String, 166 | pub time: u64, 167 | } 168 | 169 | impl Eip712 for SpotSend { 170 | fn domain(&self) -> Eip712Domain { 171 | eip_712_domain(self.signature_chain_id) 172 | } 173 | 174 | fn struct_hash(&self) -> B256 { 175 | let items = ( 176 | keccak256("HyperliquidTransaction:SpotSend(string hyperliquidChain,string destination,string token,string amount,uint64 time)"), 177 | keccak256(&self.hyperliquid_chain), 178 | keccak256(&self.destination), 179 | keccak256(&self.token), 180 | keccak256(&self.amount), 181 | &self.time, 182 | ); 183 | keccak256(items.abi_encode()) 184 | } 185 | } 186 | 187 | #[derive(Serialize, Deserialize, Debug, Clone)] 188 | #[serde(rename_all = "camelCase")] 189 | pub struct SpotUser { 190 | pub class_transfer: ClassTransfer, 191 | } 192 | 193 | #[derive(Serialize, Deserialize, Debug, Clone)] 194 | #[serde(rename_all = "camelCase")] 195 | pub struct ClassTransfer { 196 | pub usdc: u64, 197 | pub to_perp: bool, 198 | } 199 | 200 | #[derive(Serialize, Deserialize, Debug, Clone)] 201 | #[serde(rename_all = "camelCase")] 202 | pub struct SendAsset { 203 | #[serde(serialize_with = "serialize_hex")] 204 | pub signature_chain_id: u64, 205 | pub hyperliquid_chain: String, 206 | pub destination: String, 207 | pub source_dex: String, 208 | pub destination_dex: String, 209 | pub token: String, 210 | pub amount: String, 211 | pub from_sub_account: String, 212 | pub nonce: u64, 213 | } 214 | 215 | impl Eip712 for SendAsset { 216 | fn domain(&self) -> Eip712Domain { 217 | eip_712_domain(self.signature_chain_id) 218 | } 219 | 220 | fn struct_hash(&self) -> B256 { 221 | let items = ( 222 | keccak256("HyperliquidTransaction:SendAsset(string hyperliquidChain,string destination,string sourceDex,string destinationDex,string token,string amount,string fromSubAccount,uint64 nonce)"), 223 | keccak256(&self.hyperliquid_chain), 224 | keccak256(&self.destination), 225 | keccak256(&self.source_dex), 226 | keccak256(&self.destination_dex), 227 | keccak256(&self.token), 228 | keccak256(&self.amount), 229 | keccak256(&self.from_sub_account), 230 | &self.nonce, 231 | ); 232 | keccak256(items.abi_encode()) 233 | } 234 | } 235 | 236 | #[derive(Serialize, Deserialize, Debug, Clone)] 237 | #[serde(rename_all = "camelCase")] 238 | pub struct VaultTransfer { 239 | pub vault_address: Address, 240 | pub is_deposit: bool, 241 | pub usd: u64, 242 | } 243 | 244 | #[derive(Serialize, Deserialize, Debug, Clone)] 245 | #[serde(rename_all = "camelCase")] 246 | pub struct SetReferrer { 247 | pub code: String, 248 | } 249 | 250 | #[derive(Serialize, Deserialize, Debug, Clone)] 251 | #[serde(rename_all = "camelCase")] 252 | pub struct EvmUserModify { 253 | pub using_big_blocks: bool, 254 | } 255 | 256 | #[derive(Serialize, Deserialize, Debug, Clone)] 257 | #[serde(rename_all = "camelCase")] 258 | pub struct ApproveBuilderFee { 259 | #[serde(serialize_with = "serialize_hex")] 260 | pub signature_chain_id: u64, 261 | pub hyperliquid_chain: String, 262 | pub builder: Address, 263 | pub max_fee_rate: String, 264 | pub nonce: u64, 265 | } 266 | 267 | #[derive(Serialize, Deserialize, Debug, Clone)] 268 | #[serde(rename_all = "camelCase")] 269 | pub struct ScheduleCancel { 270 | #[serde(skip_serializing_if = "Option::is_none")] 271 | pub time: Option, 272 | } 273 | 274 | #[derive(Serialize, Deserialize, Debug, Clone)] 275 | #[serde(rename_all = "camelCase")] 276 | pub struct ClaimRewards; 277 | 278 | impl Eip712 for ApproveBuilderFee { 279 | fn domain(&self) -> Eip712Domain { 280 | eip_712_domain(self.signature_chain_id) 281 | } 282 | 283 | fn struct_hash(&self) -> B256 { 284 | let items = ( 285 | keccak256("HyperliquidTransaction:ApproveBuilderFee(string hyperliquidChain,string maxFeeRate,address builder,uint64 nonce)"), 286 | keccak256(&self.hyperliquid_chain), 287 | keccak256(&self.max_fee_rate), 288 | &self.builder, 289 | &self.nonce, 290 | ); 291 | keccak256(items.abi_encode()) 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /src/ws/sub_structs.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use alloy::primitives::Address; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::Leverage; 7 | 8 | #[derive(Deserialize, Clone, Debug)] 9 | pub struct Trade { 10 | pub coin: String, 11 | pub side: String, 12 | pub px: String, 13 | pub sz: String, 14 | pub time: u64, 15 | pub hash: String, 16 | pub tid: u64, 17 | pub users: (String, String), 18 | } 19 | 20 | #[derive(Deserialize, Clone, Debug)] 21 | pub struct BookLevel { 22 | pub px: String, 23 | pub sz: String, 24 | pub n: u64, 25 | } 26 | 27 | #[derive(Deserialize, Clone, Debug)] 28 | pub struct L2BookData { 29 | pub coin: String, 30 | pub time: u64, 31 | pub levels: Vec>, 32 | } 33 | 34 | #[derive(Deserialize, Clone, Debug)] 35 | pub struct AllMidsData { 36 | pub mids: HashMap, 37 | } 38 | 39 | #[derive(Deserialize, Clone, Debug)] 40 | #[serde(rename_all = "camelCase")] 41 | pub struct TradeInfo { 42 | pub coin: String, 43 | pub side: String, 44 | pub px: String, 45 | pub sz: String, 46 | pub time: u64, 47 | pub hash: String, 48 | pub start_position: String, 49 | pub dir: String, 50 | pub closed_pnl: String, 51 | pub oid: u64, 52 | pub cloid: Option, 53 | pub crossed: bool, 54 | pub fee: String, 55 | pub fee_token: String, 56 | pub tid: u64, 57 | } 58 | 59 | #[derive(Deserialize, Clone, Debug)] 60 | #[serde(rename_all = "camelCase")] 61 | pub struct UserFillsData { 62 | pub is_snapshot: Option, 63 | pub user: Address, 64 | pub fills: Vec, 65 | } 66 | 67 | #[derive(Deserialize, Clone, Debug)] 68 | #[serde(rename_all = "camelCase")] 69 | pub enum UserData { 70 | Fills(Vec), 71 | Funding(UserFunding), 72 | Liquidation(Liquidation), 73 | NonUserCancel(Vec), 74 | } 75 | 76 | #[derive(Deserialize, Clone, Debug)] 77 | pub struct Liquidation { 78 | pub lid: u64, 79 | pub liquidator: String, 80 | pub liquidated_user: String, 81 | pub liquidated_ntl_pos: String, 82 | pub liquidated_account_value: String, 83 | } 84 | 85 | #[derive(Deserialize, Clone, Debug)] 86 | pub struct NonUserCancel { 87 | pub coin: String, 88 | pub oid: u64, 89 | } 90 | 91 | #[derive(Deserialize, Clone, Debug)] 92 | pub struct CandleData { 93 | #[serde(rename = "T")] 94 | pub time_close: u64, 95 | #[serde(rename = "c")] 96 | pub close: String, 97 | #[serde(rename = "h")] 98 | pub high: String, 99 | #[serde(rename = "i")] 100 | pub interval: String, 101 | #[serde(rename = "l")] 102 | pub low: String, 103 | #[serde(rename = "n")] 104 | pub num_trades: u64, 105 | #[serde(rename = "o")] 106 | pub open: String, 107 | #[serde(rename = "s")] 108 | pub coin: String, 109 | #[serde(rename = "t")] 110 | pub time_open: u64, 111 | #[serde(rename = "v")] 112 | pub volume: String, 113 | } 114 | 115 | #[derive(Deserialize, Clone, Debug)] 116 | #[serde(rename_all = "camelCase")] 117 | pub struct OrderUpdate { 118 | pub order: BasicOrder, 119 | pub status: String, 120 | pub status_timestamp: u64, 121 | } 122 | 123 | #[derive(Deserialize, Clone, Debug)] 124 | #[serde(rename_all = "camelCase")] 125 | pub struct BasicOrder { 126 | pub coin: String, 127 | pub side: String, 128 | pub limit_px: String, 129 | pub sz: String, 130 | pub oid: u64, 131 | pub timestamp: u64, 132 | pub orig_sz: String, 133 | pub cloid: Option, 134 | } 135 | 136 | #[derive(Deserialize, Clone, Debug)] 137 | #[serde(rename_all = "camelCase")] 138 | pub struct UserFundingsData { 139 | pub is_snapshot: Option, 140 | pub user: Address, 141 | pub fundings: Vec, 142 | } 143 | 144 | #[derive(Deserialize, Clone, Debug)] 145 | #[serde(rename_all = "camelCase")] 146 | pub struct UserFunding { 147 | pub time: u64, 148 | pub coin: String, 149 | pub usdc: String, 150 | pub szi: String, 151 | pub funding_rate: String, 152 | } 153 | 154 | #[derive(Deserialize, Clone, Debug)] 155 | #[serde(rename_all = "camelCase")] 156 | pub struct UserNonFundingLedgerUpdatesData { 157 | pub is_snapshot: Option, 158 | pub user: Address, 159 | pub non_funding_ledger_updates: Vec, 160 | } 161 | 162 | #[derive(Deserialize, Clone, Debug)] 163 | pub struct LedgerUpdateData { 164 | pub time: u64, 165 | pub hash: String, 166 | pub delta: LedgerUpdate, 167 | } 168 | 169 | #[derive(Deserialize, Clone, Debug)] 170 | #[serde(rename_all = "camelCase")] 171 | #[serde(tag = "type")] 172 | pub enum LedgerUpdate { 173 | Deposit(Deposit), 174 | Withdraw(Withdraw), 175 | InternalTransfer(InternalTransfer), 176 | SubAccountTransfer(SubAccountTransfer), 177 | LedgerLiquidation(LedgerLiquidation), 178 | VaultDeposit(VaultDelta), 179 | VaultCreate(VaultDelta), 180 | VaultDistribution(VaultDelta), 181 | VaultWithdraw(VaultWithdraw), 182 | VaultLeaderCommission(VaultLeaderCommission), 183 | AccountClassTransfer(AccountClassTransfer), 184 | SpotTransfer(SpotTransfer), 185 | SpotGenesis(SpotGenesis), 186 | } 187 | 188 | #[derive(Deserialize, Clone, Debug)] 189 | pub struct Deposit { 190 | pub usdc: String, 191 | } 192 | 193 | #[derive(Deserialize, Clone, Debug)] 194 | pub struct Withdraw { 195 | pub usdc: String, 196 | pub nonce: u64, 197 | pub fee: String, 198 | } 199 | 200 | #[derive(Deserialize, Clone, Debug)] 201 | pub struct InternalTransfer { 202 | pub usdc: String, 203 | pub user: Address, 204 | pub destination: Address, 205 | pub fee: String, 206 | } 207 | 208 | #[derive(Deserialize, Clone, Debug)] 209 | pub struct SubAccountTransfer { 210 | pub usdc: String, 211 | pub user: Address, 212 | pub destination: Address, 213 | } 214 | 215 | #[derive(Deserialize, Clone, Debug)] 216 | #[serde(rename_all = "camelCase")] 217 | pub struct LedgerLiquidation { 218 | pub account_value: u64, 219 | pub leverage_type: String, 220 | pub liquidated_positions: Vec, 221 | } 222 | 223 | #[derive(Deserialize, Clone, Debug)] 224 | pub struct LiquidatedPosition { 225 | pub coin: String, 226 | pub szi: String, 227 | } 228 | 229 | #[derive(Deserialize, Clone, Debug)] 230 | pub struct VaultDelta { 231 | pub vault: Address, 232 | pub usdc: String, 233 | } 234 | 235 | #[derive(Deserialize, Clone, Debug)] 236 | #[serde(rename_all = "camelCase")] 237 | pub struct VaultWithdraw { 238 | pub vault: Address, 239 | pub user: Address, 240 | pub requested_usd: String, 241 | pub commission: String, 242 | pub closing_cost: String, 243 | pub basis: String, 244 | pub net_withdrawn_usd: String, 245 | } 246 | 247 | #[derive(Deserialize, Clone, Debug)] 248 | pub struct VaultLeaderCommission { 249 | pub user: Address, 250 | pub usdc: String, 251 | } 252 | 253 | #[derive(Deserialize, Clone, Debug)] 254 | #[serde(rename_all = "camelCase")] 255 | pub struct AccountClassTransfer { 256 | pub usdc: String, 257 | pub to_perp: bool, 258 | } 259 | 260 | #[derive(Deserialize, Clone, Debug)] 261 | #[serde(rename_all = "camelCase")] 262 | pub struct SpotTransfer { 263 | pub token: String, 264 | pub amount: String, 265 | pub usdc_value: String, 266 | pub user: Address, 267 | pub destination: Address, 268 | pub fee: String, 269 | } 270 | 271 | #[derive(Deserialize, Clone, Debug)] 272 | pub struct SpotGenesis { 273 | pub token: String, 274 | pub amount: String, 275 | } 276 | 277 | #[derive(Deserialize, Clone, Debug)] 278 | pub struct NotificationData { 279 | pub notification: String, 280 | } 281 | 282 | #[derive(Deserialize, Clone, Debug)] 283 | #[serde(rename_all = "camelCase")] 284 | pub struct WebData2Data { 285 | pub user: Address, 286 | } 287 | 288 | #[derive(Deserialize, Clone, Debug)] 289 | #[serde(rename_all = "camelCase")] 290 | pub struct ActiveAssetCtxData { 291 | pub coin: String, 292 | pub ctx: AssetCtx, 293 | } 294 | 295 | #[derive(Deserialize, Serialize, Clone, Debug)] 296 | #[serde(rename_all = "camelCase")] 297 | #[serde(untagged)] 298 | pub enum AssetCtx { 299 | Perps(PerpsAssetCtx), 300 | Spot(SpotAssetCtx), 301 | } 302 | 303 | #[derive(Deserialize, Serialize, Clone, Debug)] 304 | #[serde(rename_all = "camelCase")] 305 | pub struct SharedAssetCtx { 306 | pub day_ntl_vlm: String, 307 | pub prev_day_px: String, 308 | pub mark_px: String, 309 | pub mid_px: Option, 310 | } 311 | 312 | #[derive(Deserialize, Serialize, Clone, Debug)] 313 | #[serde(rename_all = "camelCase")] 314 | pub struct PerpsAssetCtx { 315 | #[serde(flatten)] 316 | pub shared: SharedAssetCtx, 317 | pub funding: String, 318 | pub open_interest: String, 319 | pub oracle_px: String, 320 | } 321 | 322 | #[derive(Deserialize, Clone, Debug)] 323 | #[serde(rename_all = "camelCase")] 324 | pub struct ActiveSpotAssetCtxData { 325 | pub coin: String, 326 | pub ctx: SpotAssetCtx, 327 | } 328 | 329 | #[derive(Deserialize, Serialize, Clone, Debug)] 330 | #[serde(rename_all = "camelCase")] 331 | pub struct SpotAssetCtx { 332 | #[serde(flatten)] 333 | pub shared: SharedAssetCtx, 334 | pub circulating_supply: String, 335 | } 336 | 337 | #[derive(Deserialize, Serialize, Clone, Debug)] 338 | #[serde(rename_all = "camelCase")] 339 | pub struct ActiveAssetDataData { 340 | pub user: Address, 341 | pub coin: String, 342 | pub leverage: Leverage, 343 | pub max_trade_szs: Vec, 344 | pub available_to_trade: Vec, 345 | } 346 | 347 | #[derive(Deserialize, Clone, Debug)] 348 | #[serde(rename_all = "camelCase")] 349 | pub struct BboData { 350 | pub coin: String, 351 | pub time: u64, 352 | pub bbo: Vec>, 353 | } 354 | -------------------------------------------------------------------------------- /src/info/info_client.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use alloy::primitives::Address; 4 | use reqwest::Client; 5 | use serde::{Deserialize, Serialize}; 6 | use tokio::sync::mpsc::UnboundedSender; 7 | 8 | use crate::{ 9 | info::{ 10 | ActiveAssetDataResponse, CandlesSnapshotResponse, FundingHistoryResponse, 11 | L2SnapshotResponse, OpenOrdersResponse, OrderInfo, RecentTradesResponse, UserFillsResponse, 12 | UserStateResponse, 13 | }, 14 | meta::{AssetContext, Meta, SpotMeta, SpotMetaAndAssetCtxs}, 15 | prelude::*, 16 | req::HttpClient, 17 | ws::{Subscription, WsManager}, 18 | BaseUrl, Error, Message, OrderStatusResponse, ReferralResponse, UserFeesResponse, 19 | UserFundingResponse, UserTokenBalanceResponse, 20 | }; 21 | 22 | #[derive(Deserialize, Serialize, Debug, Clone)] 23 | #[serde(rename_all = "camelCase")] 24 | pub struct CandleSnapshotRequest { 25 | coin: String, 26 | interval: String, 27 | start_time: u64, 28 | end_time: u64, 29 | } 30 | 31 | #[derive(Deserialize, Serialize, Debug, Clone)] 32 | #[serde(tag = "type")] 33 | #[serde(rename_all = "camelCase")] 34 | pub enum InfoRequest { 35 | #[serde(rename = "clearinghouseState")] 36 | UserState { 37 | user: Address, 38 | }, 39 | #[serde(rename = "batchClearinghouseStates")] 40 | UserStates { 41 | users: Vec
, 42 | }, 43 | #[serde(rename = "spotClearinghouseState")] 44 | UserTokenBalances { 45 | user: Address, 46 | }, 47 | UserFees { 48 | user: Address, 49 | }, 50 | OpenOrders { 51 | user: Address, 52 | }, 53 | OrderStatus { 54 | user: Address, 55 | oid: u64, 56 | }, 57 | Meta, 58 | MetaAndAssetCtxs, 59 | SpotMeta, 60 | SpotMetaAndAssetCtxs, 61 | AllMids, 62 | UserFills { 63 | user: Address, 64 | }, 65 | #[serde(rename_all = "camelCase")] 66 | FundingHistory { 67 | coin: String, 68 | start_time: u64, 69 | end_time: Option, 70 | }, 71 | #[serde(rename_all = "camelCase")] 72 | UserFunding { 73 | user: Address, 74 | start_time: u64, 75 | end_time: Option, 76 | }, 77 | L2Book { 78 | coin: String, 79 | }, 80 | RecentTrades { 81 | coin: String, 82 | }, 83 | #[serde(rename_all = "camelCase")] 84 | CandleSnapshot { 85 | req: CandleSnapshotRequest, 86 | }, 87 | Referral { 88 | user: Address, 89 | }, 90 | HistoricalOrders { 91 | user: Address, 92 | }, 93 | ActiveAssetData { 94 | user: Address, 95 | coin: String, 96 | }, 97 | } 98 | 99 | #[derive(Debug)] 100 | pub struct InfoClient { 101 | pub http_client: HttpClient, 102 | pub(crate) ws_manager: Option, 103 | reconnect: bool, 104 | } 105 | 106 | impl InfoClient { 107 | pub async fn new(client: Option, base_url: Option) -> Result { 108 | Self::new_internal(client, base_url, false).await 109 | } 110 | 111 | pub async fn with_reconnect( 112 | client: Option, 113 | base_url: Option, 114 | ) -> Result { 115 | Self::new_internal(client, base_url, true).await 116 | } 117 | 118 | async fn new_internal( 119 | client: Option, 120 | base_url: Option, 121 | reconnect: bool, 122 | ) -> Result { 123 | let client = client.unwrap_or_default(); 124 | let base_url = base_url.unwrap_or(BaseUrl::Mainnet).get_url(); 125 | 126 | Ok(InfoClient { 127 | http_client: HttpClient { client, base_url }, 128 | ws_manager: None, 129 | reconnect, 130 | }) 131 | } 132 | 133 | pub async fn subscribe( 134 | &mut self, 135 | subscription: Subscription, 136 | sender_channel: UnboundedSender, 137 | ) -> Result { 138 | if self.ws_manager.is_none() { 139 | let ws_manager = WsManager::new( 140 | format!("ws{}/ws", &self.http_client.base_url[4..]), 141 | self.reconnect, 142 | ) 143 | .await?; 144 | self.ws_manager = Some(ws_manager); 145 | } 146 | 147 | let identifier = 148 | serde_json::to_string(&subscription).map_err(|e| Error::JsonParse(e.to_string()))?; 149 | 150 | self.ws_manager 151 | .as_mut() 152 | .ok_or(Error::WsManagerNotFound)? 153 | .add_subscription(identifier, sender_channel) 154 | .await 155 | } 156 | 157 | pub async fn unsubscribe(&mut self, subscription_id: u32) -> Result<()> { 158 | if self.ws_manager.is_none() { 159 | let ws_manager = WsManager::new( 160 | format!("ws{}/ws", &self.http_client.base_url[4..]), 161 | self.reconnect, 162 | ) 163 | .await?; 164 | self.ws_manager = Some(ws_manager); 165 | } 166 | 167 | self.ws_manager 168 | .as_mut() 169 | .ok_or(Error::WsManagerNotFound)? 170 | .remove_subscription(subscription_id) 171 | .await 172 | } 173 | 174 | async fn send_info_request Deserialize<'a>>( 175 | &self, 176 | info_request: InfoRequest, 177 | ) -> Result { 178 | let data = 179 | serde_json::to_string(&info_request).map_err(|e| Error::JsonParse(e.to_string()))?; 180 | 181 | let return_data = self.http_client.post("/info", data).await?; 182 | serde_json::from_str(&return_data).map_err(|e| Error::JsonParse(e.to_string())) 183 | } 184 | 185 | pub async fn open_orders(&self, address: Address) -> Result> { 186 | let input = InfoRequest::OpenOrders { user: address }; 187 | self.send_info_request(input).await 188 | } 189 | 190 | pub async fn user_state(&self, address: Address) -> Result { 191 | let input = InfoRequest::UserState { user: address }; 192 | self.send_info_request(input).await 193 | } 194 | 195 | pub async fn user_states(&self, addresses: Vec
) -> Result> { 196 | let input = InfoRequest::UserStates { users: addresses }; 197 | self.send_info_request(input).await 198 | } 199 | 200 | pub async fn user_token_balances(&self, address: Address) -> Result { 201 | let input = InfoRequest::UserTokenBalances { user: address }; 202 | self.send_info_request(input).await 203 | } 204 | 205 | pub async fn user_fees(&self, address: Address) -> Result { 206 | let input = InfoRequest::UserFees { user: address }; 207 | self.send_info_request(input).await 208 | } 209 | 210 | pub async fn meta(&self) -> Result { 211 | let input = InfoRequest::Meta; 212 | self.send_info_request(input).await 213 | } 214 | 215 | pub async fn meta_and_asset_contexts(&self) -> Result<(Meta, Vec)> { 216 | let input = InfoRequest::MetaAndAssetCtxs; 217 | self.send_info_request(input).await 218 | } 219 | 220 | pub async fn spot_meta(&self) -> Result { 221 | let input = InfoRequest::SpotMeta; 222 | self.send_info_request(input).await 223 | } 224 | 225 | pub async fn spot_meta_and_asset_contexts(&self) -> Result> { 226 | let input = InfoRequest::SpotMetaAndAssetCtxs; 227 | self.send_info_request(input).await 228 | } 229 | 230 | pub async fn all_mids(&self) -> Result> { 231 | let input = InfoRequest::AllMids; 232 | self.send_info_request(input).await 233 | } 234 | 235 | pub async fn user_fills(&self, address: Address) -> Result> { 236 | let input = InfoRequest::UserFills { user: address }; 237 | self.send_info_request(input).await 238 | } 239 | 240 | pub async fn funding_history( 241 | &self, 242 | coin: String, 243 | start_time: u64, 244 | end_time: Option, 245 | ) -> Result> { 246 | let input = InfoRequest::FundingHistory { 247 | coin, 248 | start_time, 249 | end_time, 250 | }; 251 | self.send_info_request(input).await 252 | } 253 | 254 | pub async fn user_funding_history( 255 | &self, 256 | user: Address, 257 | start_time: u64, 258 | end_time: Option, 259 | ) -> Result> { 260 | let input = InfoRequest::UserFunding { 261 | user, 262 | start_time, 263 | end_time, 264 | }; 265 | self.send_info_request(input).await 266 | } 267 | 268 | pub async fn recent_trades(&self, coin: String) -> Result> { 269 | let input = InfoRequest::RecentTrades { coin }; 270 | self.send_info_request(input).await 271 | } 272 | 273 | pub async fn l2_snapshot(&self, coin: String) -> Result { 274 | let input = InfoRequest::L2Book { coin }; 275 | self.send_info_request(input).await 276 | } 277 | 278 | pub async fn candles_snapshot( 279 | &self, 280 | coin: String, 281 | interval: String, 282 | start_time: u64, 283 | end_time: u64, 284 | ) -> Result> { 285 | let input = InfoRequest::CandleSnapshot { 286 | req: CandleSnapshotRequest { 287 | coin, 288 | interval, 289 | start_time, 290 | end_time, 291 | }, 292 | }; 293 | self.send_info_request(input).await 294 | } 295 | 296 | pub async fn query_order_by_oid( 297 | &self, 298 | address: Address, 299 | oid: u64, 300 | ) -> Result { 301 | let input = InfoRequest::OrderStatus { user: address, oid }; 302 | self.send_info_request(input).await 303 | } 304 | 305 | pub async fn query_referral_state(&self, address: Address) -> Result { 306 | let input = InfoRequest::Referral { user: address }; 307 | self.send_info_request(input).await 308 | } 309 | 310 | pub async fn historical_orders(&self, address: Address) -> Result> { 311 | let input = InfoRequest::HistoricalOrders { user: address }; 312 | self.send_info_request(input).await 313 | } 314 | 315 | pub async fn active_asset_data( 316 | &self, 317 | user: Address, 318 | coin: String, 319 | ) -> Result { 320 | let input = InfoRequest::ActiveAssetData { user, coin }; 321 | self.send_info_request(input).await 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/market_maker.rs: -------------------------------------------------------------------------------- 1 | use alloy::{primitives::Address, signers::local::PrivateKeySigner}; 2 | use log::{error, info}; 3 | use tokio::sync::mpsc::unbounded_channel; 4 | 5 | use crate::{ 6 | bps_diff, truncate_float, BaseUrl, ClientCancelRequest, ClientLimit, ClientOrder, 7 | ClientOrderRequest, ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, InfoClient, 8 | Message, Subscription, UserData, EPSILON, 9 | }; 10 | #[derive(Debug)] 11 | pub struct MarketMakerRestingOrder { 12 | pub oid: u64, 13 | pub position: f64, 14 | pub price: f64, 15 | } 16 | 17 | #[derive(Debug)] 18 | pub struct MarketMakerInput { 19 | pub asset: String, 20 | pub target_liquidity: f64, // Amount of liquidity on both sides to target 21 | pub half_spread: u16, // Half of the spread for our market making (in BPS) 22 | pub max_bps_diff: u16, // Max deviation before we cancel and put new orders on the book (in BPS) 23 | pub max_absolute_position_size: f64, // Absolute value of the max position we can take on 24 | pub decimals: u32, // Decimals to round to for pricing 25 | pub wallet: PrivateKeySigner, // Wallet containing private key 26 | } 27 | 28 | #[derive(Debug)] 29 | pub struct MarketMaker { 30 | pub asset: String, 31 | pub target_liquidity: f64, 32 | pub half_spread: u16, 33 | pub max_bps_diff: u16, 34 | pub max_absolute_position_size: f64, 35 | pub decimals: u32, 36 | pub lower_resting: MarketMakerRestingOrder, 37 | pub upper_resting: MarketMakerRestingOrder, 38 | pub cur_position: f64, 39 | pub latest_mid_price: f64, 40 | pub info_client: InfoClient, 41 | pub exchange_client: ExchangeClient, 42 | pub user_address: Address, 43 | } 44 | 45 | impl MarketMaker { 46 | pub async fn new(input: MarketMakerInput) -> MarketMaker { 47 | let user_address = input.wallet.address(); 48 | 49 | let info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 50 | let exchange_client = 51 | ExchangeClient::new(None, input.wallet, Some(BaseUrl::Testnet), None, None) 52 | .await 53 | .unwrap(); 54 | 55 | MarketMaker { 56 | asset: input.asset, 57 | target_liquidity: input.target_liquidity, 58 | half_spread: input.half_spread, 59 | max_bps_diff: input.max_bps_diff, 60 | max_absolute_position_size: input.max_absolute_position_size, 61 | decimals: input.decimals, 62 | lower_resting: MarketMakerRestingOrder { 63 | oid: 0, 64 | position: 0.0, 65 | price: -1.0, 66 | }, 67 | upper_resting: MarketMakerRestingOrder { 68 | oid: 0, 69 | position: 0.0, 70 | price: -1.0, 71 | }, 72 | cur_position: 0.0, 73 | latest_mid_price: -1.0, 74 | info_client, 75 | exchange_client, 76 | user_address, 77 | } 78 | } 79 | 80 | pub async fn start(&mut self) { 81 | let (sender, mut receiver) = unbounded_channel(); 82 | 83 | // Subscribe to UserEvents for fills 84 | self.info_client 85 | .subscribe( 86 | Subscription::UserEvents { 87 | user: self.user_address, 88 | }, 89 | sender.clone(), 90 | ) 91 | .await 92 | .unwrap(); 93 | 94 | // Subscribe to AllMids so we can market make around the mid price 95 | self.info_client 96 | .subscribe(Subscription::AllMids, sender) 97 | .await 98 | .unwrap(); 99 | 100 | loop { 101 | let message = receiver.recv().await.unwrap(); 102 | match message { 103 | Message::AllMids(all_mids) => { 104 | let all_mids = all_mids.data.mids; 105 | let mid = all_mids.get(&self.asset); 106 | if let Some(mid) = mid { 107 | let mid: f64 = mid.parse().unwrap(); 108 | self.latest_mid_price = mid; 109 | // Check to see if we need to cancel or place any new orders 110 | self.potentially_update().await; 111 | } else { 112 | error!( 113 | "could not get mid for asset {}: {all_mids:?}", 114 | self.asset.clone() 115 | ); 116 | } 117 | } 118 | Message::User(user_events) => { 119 | // We haven't seen the first mid price event yet, so just continue 120 | if self.latest_mid_price < 0.0 { 121 | continue; 122 | } 123 | let user_events = user_events.data; 124 | if let UserData::Fills(fills) = user_events { 125 | for fill in fills { 126 | let amount: f64 = fill.sz.parse().unwrap(); 127 | // Update our resting positions whenever we see a fill 128 | if fill.side.eq("B") { 129 | self.cur_position += amount; 130 | self.lower_resting.position -= amount; 131 | info!("Fill: bought {amount} {}", self.asset.clone()); 132 | } else { 133 | self.cur_position -= amount; 134 | self.upper_resting.position -= amount; 135 | info!("Fill: sold {amount} {}", self.asset.clone()); 136 | } 137 | } 138 | } 139 | // Check to see if we need to cancel or place any new orders 140 | self.potentially_update().await; 141 | } 142 | _ => { 143 | panic!("Unsupported message type"); 144 | } 145 | } 146 | } 147 | } 148 | 149 | async fn attempt_cancel(&self, asset: String, oid: u64) -> bool { 150 | let cancel = self 151 | .exchange_client 152 | .cancel(ClientCancelRequest { asset, oid }, None) 153 | .await; 154 | 155 | match cancel { 156 | Ok(cancel) => match cancel { 157 | ExchangeResponseStatus::Ok(cancel) => { 158 | if let Some(cancel) = cancel.data { 159 | if !cancel.statuses.is_empty() { 160 | match cancel.statuses[0].clone() { 161 | ExchangeDataStatus::Success => { 162 | return true; 163 | } 164 | ExchangeDataStatus::Error(e) => { 165 | error!("Error with cancelling: {e}") 166 | } 167 | _ => unreachable!(), 168 | } 169 | } else { 170 | error!("Exchange data statuses is empty when cancelling: {cancel:?}") 171 | } 172 | } else { 173 | error!("Exchange response data is empty when cancelling: {cancel:?}") 174 | } 175 | } 176 | ExchangeResponseStatus::Err(e) => error!("Error with cancelling: {e}"), 177 | }, 178 | Err(e) => error!("Error with cancelling: {e}"), 179 | } 180 | false 181 | } 182 | 183 | async fn place_order( 184 | &self, 185 | asset: String, 186 | amount: f64, 187 | price: f64, 188 | is_buy: bool, 189 | ) -> (f64, u64) { 190 | let order = self 191 | .exchange_client 192 | .order( 193 | ClientOrderRequest { 194 | asset, 195 | is_buy, 196 | reduce_only: false, 197 | limit_px: price, 198 | sz: amount, 199 | cloid: None, 200 | order_type: ClientOrder::Limit(ClientLimit { 201 | tif: "Gtc".to_string(), 202 | }), 203 | }, 204 | None, 205 | ) 206 | .await; 207 | match order { 208 | Ok(order) => match order { 209 | ExchangeResponseStatus::Ok(order) => { 210 | if let Some(order) = order.data { 211 | if !order.statuses.is_empty() { 212 | match order.statuses[0].clone() { 213 | ExchangeDataStatus::Filled(order) => { 214 | return (amount, order.oid); 215 | } 216 | ExchangeDataStatus::Resting(order) => { 217 | return (amount, order.oid); 218 | } 219 | ExchangeDataStatus::Error(e) => { 220 | error!("Error with placing order: {e}") 221 | } 222 | _ => unreachable!(), 223 | } 224 | } else { 225 | error!("Exchange data statuses is empty when placing order: {order:?}") 226 | } 227 | } else { 228 | error!("Exchange response data is empty when placing order: {order:?}") 229 | } 230 | } 231 | ExchangeResponseStatus::Err(e) => { 232 | error!("Error with placing order: {e}") 233 | } 234 | }, 235 | Err(e) => error!("Error with placing order: {e}"), 236 | } 237 | (0.0, 0) 238 | } 239 | 240 | async fn potentially_update(&mut self) { 241 | let half_spread = (self.latest_mid_price * self.half_spread as f64) / 10000.0; 242 | // Determine prices to target from the half spread 243 | let (lower_price, upper_price) = ( 244 | self.latest_mid_price - half_spread, 245 | self.latest_mid_price + half_spread, 246 | ); 247 | let (mut lower_price, mut upper_price) = ( 248 | truncate_float(lower_price, self.decimals, true), 249 | truncate_float(upper_price, self.decimals, false), 250 | ); 251 | 252 | // Rounding optimistically to make our market tighter might cause a weird edge case, so account for that 253 | if (lower_price - upper_price).abs() < EPSILON { 254 | lower_price = truncate_float(lower_price, self.decimals, false); 255 | upper_price = truncate_float(upper_price, self.decimals, true); 256 | } 257 | 258 | // Determine amounts we can put on the book without exceeding the max absolute position size 259 | let lower_order_amount = (self.max_absolute_position_size - self.cur_position) 260 | .min(self.target_liquidity) 261 | .max(0.0); 262 | 263 | let upper_order_amount = (self.max_absolute_position_size + self.cur_position) 264 | .min(self.target_liquidity) 265 | .max(0.0); 266 | 267 | // Determine if we need to cancel the resting order and put a new order up due to deviation 268 | let lower_change = (lower_order_amount - self.lower_resting.position).abs() > EPSILON 269 | || bps_diff(lower_price, self.lower_resting.price) > self.max_bps_diff; 270 | let upper_change = (upper_order_amount - self.upper_resting.position).abs() > EPSILON 271 | || bps_diff(upper_price, self.upper_resting.price) > self.max_bps_diff; 272 | 273 | // Consider cancelling 274 | // TODO: Don't block on cancels 275 | if self.lower_resting.oid != 0 && self.lower_resting.position > EPSILON && lower_change { 276 | let cancel = self 277 | .attempt_cancel(self.asset.clone(), self.lower_resting.oid) 278 | .await; 279 | // If we were unable to cancel, it means we got a fill, so wait until we receive that event to do anything 280 | if !cancel { 281 | return; 282 | } 283 | info!("Cancelled buy order: {:?}", self.lower_resting); 284 | } 285 | 286 | if self.upper_resting.oid != 0 && self.upper_resting.position > EPSILON && upper_change { 287 | let cancel = self 288 | .attempt_cancel(self.asset.clone(), self.upper_resting.oid) 289 | .await; 290 | if !cancel { 291 | return; 292 | } 293 | info!("Cancelled sell order: {:?}", self.upper_resting); 294 | } 295 | 296 | // Consider putting a new order up 297 | if lower_order_amount > EPSILON && lower_change { 298 | let (amount_resting, oid) = self 299 | .place_order(self.asset.clone(), lower_order_amount, lower_price, true) 300 | .await; 301 | 302 | self.lower_resting.oid = oid; 303 | self.lower_resting.position = amount_resting; 304 | self.lower_resting.price = lower_price; 305 | 306 | if amount_resting > EPSILON { 307 | info!( 308 | "Buy for {amount_resting} {} resting at {lower_price}", 309 | self.asset.clone() 310 | ); 311 | } 312 | } 313 | 314 | if upper_order_amount > EPSILON && upper_change { 315 | let (amount_resting, oid) = self 316 | .place_order(self.asset.clone(), upper_order_amount, upper_price, false) 317 | .await; 318 | self.upper_resting.oid = oid; 319 | self.upper_resting.position = amount_resting; 320 | self.upper_resting.price = upper_price; 321 | 322 | if amount_resting > EPSILON { 323 | info!( 324 | "Sell for {amount_resting} {} resting at {upper_price}", 325 | self.asset.clone() 326 | ); 327 | } 328 | } 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/ws/ws_manager.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | borrow::BorrowMut, 3 | collections::HashMap, 4 | ops::DerefMut, 5 | sync::{ 6 | atomic::{AtomicBool, Ordering}, 7 | Arc, 8 | }, 9 | time::Duration, 10 | }; 11 | 12 | use alloy::primitives::Address; 13 | use futures_util::{stream::SplitSink, SinkExt, StreamExt}; 14 | use log::{error, info, warn}; 15 | use serde::{Deserialize, Serialize}; 16 | use tokio::{ 17 | net::TcpStream, 18 | spawn, 19 | sync::{mpsc::UnboundedSender, Mutex}, 20 | time, 21 | }; 22 | use tokio_tungstenite::{ 23 | connect_async, 24 | tungstenite::{self, protocol}, 25 | MaybeTlsStream, WebSocketStream, 26 | }; 27 | 28 | use crate::{ 29 | prelude::*, 30 | ws::message_types::{ 31 | ActiveAssetData, ActiveSpotAssetCtx, AllMids, Bbo, Candle, L2Book, OrderUpdates, Trades, 32 | User, 33 | }, 34 | ActiveAssetCtx, Error, Notification, UserFills, UserFundings, UserNonFundingLedgerUpdates, 35 | WebData2, 36 | }; 37 | 38 | #[derive(Debug)] 39 | struct SubscriptionData { 40 | sending_channel: UnboundedSender, 41 | subscription_id: u32, 42 | id: String, 43 | } 44 | #[derive(Debug)] 45 | pub(crate) struct WsManager { 46 | stop_flag: Arc, 47 | writer: Arc>, protocol::Message>>>, 48 | subscriptions: Arc>>>, 49 | subscription_id: u32, 50 | subscription_identifiers: HashMap, 51 | } 52 | 53 | #[derive(Serialize, Deserialize, Debug)] 54 | #[serde(tag = "type")] 55 | #[serde(rename_all = "camelCase")] 56 | pub enum Subscription { 57 | AllMids, 58 | Notification { user: Address }, 59 | WebData2 { user: Address }, 60 | Candle { coin: String, interval: String }, 61 | L2Book { coin: String }, 62 | Trades { coin: String }, 63 | OrderUpdates { user: Address }, 64 | UserEvents { user: Address }, 65 | UserFills { user: Address }, 66 | UserFundings { user: Address }, 67 | UserNonFundingLedgerUpdates { user: Address }, 68 | ActiveAssetCtx { coin: String }, 69 | ActiveAssetData { user: Address, coin: String }, 70 | Bbo { coin: String }, 71 | } 72 | 73 | #[derive(Deserialize, Clone, Debug)] 74 | #[serde(tag = "channel")] 75 | #[serde(rename_all = "camelCase")] 76 | pub enum Message { 77 | NoData, 78 | HyperliquidError(String), 79 | AllMids(AllMids), 80 | Trades(Trades), 81 | L2Book(L2Book), 82 | User(User), 83 | UserFills(UserFills), 84 | Candle(Candle), 85 | SubscriptionResponse, 86 | OrderUpdates(OrderUpdates), 87 | UserFundings(UserFundings), 88 | UserNonFundingLedgerUpdates(UserNonFundingLedgerUpdates), 89 | Notification(Notification), 90 | WebData2(WebData2), 91 | ActiveAssetCtx(ActiveAssetCtx), 92 | ActiveAssetData(ActiveAssetData), 93 | ActiveSpotAssetCtx(ActiveSpotAssetCtx), 94 | Bbo(Bbo), 95 | Pong, 96 | } 97 | 98 | #[derive(Serialize)] 99 | pub(crate) struct SubscriptionSendData<'a> { 100 | method: &'static str, 101 | subscription: &'a serde_json::Value, 102 | } 103 | 104 | #[derive(Serialize)] 105 | pub(crate) struct Ping { 106 | method: &'static str, 107 | } 108 | 109 | impl WsManager { 110 | const SEND_PING_INTERVAL: u64 = 50; 111 | 112 | pub(crate) async fn new(url: String, reconnect: bool) -> Result { 113 | let stop_flag = Arc::new(AtomicBool::new(false)); 114 | 115 | let (writer, mut reader) = Self::connect(&url).await?.split(); 116 | let writer = Arc::new(Mutex::new(writer)); 117 | 118 | let subscriptions_map: HashMap> = HashMap::new(); 119 | let subscriptions = Arc::new(Mutex::new(subscriptions_map)); 120 | let subscriptions_copy = Arc::clone(&subscriptions); 121 | 122 | { 123 | let writer = writer.clone(); 124 | let stop_flag = Arc::clone(&stop_flag); 125 | let reader_fut = async move { 126 | while !stop_flag.load(Ordering::Relaxed) { 127 | if let Some(data) = reader.next().await { 128 | if let Err(err) = 129 | WsManager::parse_and_send_data(data, &subscriptions_copy).await 130 | { 131 | error!("Error processing data received by WsManager reader: {err}"); 132 | } 133 | } else { 134 | warn!("WsManager disconnected"); 135 | if let Err(err) = WsManager::send_to_all_subscriptions( 136 | &subscriptions_copy, 137 | Message::NoData, 138 | ) 139 | .await 140 | { 141 | warn!("Error sending disconnection notification err={err}"); 142 | } 143 | if reconnect { 144 | // Always sleep for 1 second before attempting to reconnect so it does not spin during reconnecting. This could be enhanced with exponential backoff. 145 | tokio::time::sleep(Duration::from_secs(1)).await; 146 | info!("WsManager attempting to reconnect"); 147 | match Self::connect(&url).await { 148 | Ok(ws) => { 149 | let (new_writer, new_reader) = ws.split(); 150 | reader = new_reader; 151 | let mut writer_guard = writer.lock().await; 152 | *writer_guard = new_writer; 153 | for (identifier, v) in subscriptions_copy.lock().await.iter() { 154 | // TODO should these special keys be removed and instead use the simpler direct identifier mapping? 155 | if identifier.eq("userEvents") 156 | || identifier.eq("orderUpdates") 157 | { 158 | for subscription_data in v { 159 | if let Err(err) = Self::subscribe( 160 | writer_guard.deref_mut(), 161 | &subscription_data.id, 162 | ) 163 | .await 164 | { 165 | error!( 166 | "Could not resubscribe {identifier}: {err}" 167 | ); 168 | } 169 | } 170 | } else if let Err(err) = 171 | Self::subscribe(writer_guard.deref_mut(), identifier) 172 | .await 173 | { 174 | error!("Could not resubscribe correctly {identifier}: {err}"); 175 | } 176 | } 177 | info!("WsManager reconnect finished"); 178 | } 179 | Err(err) => error!("Could not connect to websocket {err}"), 180 | } 181 | } else { 182 | error!("WsManager reconnection disabled. Will not reconnect and exiting reader task."); 183 | break; 184 | } 185 | } 186 | } 187 | warn!("ws message reader task stopped"); 188 | }; 189 | spawn(reader_fut); 190 | } 191 | 192 | { 193 | let stop_flag = Arc::clone(&stop_flag); 194 | let writer = Arc::clone(&writer); 195 | let ping_fut = async move { 196 | while !stop_flag.load(Ordering::Relaxed) { 197 | match serde_json::to_string(&Ping { method: "ping" }) { 198 | Ok(payload) => { 199 | let mut writer = writer.lock().await; 200 | if let Err(err) = writer.send(protocol::Message::Text(payload)).await { 201 | error!("Error pinging server: {err}") 202 | } 203 | } 204 | Err(err) => error!("Error serializing ping message: {err}"), 205 | } 206 | time::sleep(Duration::from_secs(Self::SEND_PING_INTERVAL)).await; 207 | } 208 | warn!("ws ping task stopped"); 209 | }; 210 | spawn(ping_fut); 211 | } 212 | 213 | Ok(WsManager { 214 | stop_flag, 215 | writer, 216 | subscriptions, 217 | subscription_id: 0, 218 | subscription_identifiers: HashMap::new(), 219 | }) 220 | } 221 | 222 | async fn connect(url: &str) -> Result>> { 223 | Ok(connect_async(url) 224 | .await 225 | .map_err(|e| Error::Websocket(e.to_string()))? 226 | .0) 227 | } 228 | 229 | fn get_identifier(message: &Message) -> Result { 230 | match message { 231 | Message::AllMids(_) => serde_json::to_string(&Subscription::AllMids) 232 | .map_err(|e| Error::JsonParse(e.to_string())), 233 | Message::User(_) => Ok("userEvents".to_string()), 234 | Message::UserFills(fills) => serde_json::to_string(&Subscription::UserFills { 235 | user: fills.data.user, 236 | }) 237 | .map_err(|e| Error::JsonParse(e.to_string())), 238 | Message::Trades(trades) => { 239 | if trades.data.is_empty() { 240 | Ok(String::default()) 241 | } else { 242 | serde_json::to_string(&Subscription::Trades { 243 | coin: trades.data[0].coin.clone(), 244 | }) 245 | .map_err(|e| Error::JsonParse(e.to_string())) 246 | } 247 | } 248 | Message::L2Book(l2_book) => serde_json::to_string(&Subscription::L2Book { 249 | coin: l2_book.data.coin.clone(), 250 | }) 251 | .map_err(|e| Error::JsonParse(e.to_string())), 252 | Message::Candle(candle) => serde_json::to_string(&Subscription::Candle { 253 | coin: candle.data.coin.clone(), 254 | interval: candle.data.interval.clone(), 255 | }) 256 | .map_err(|e| Error::JsonParse(e.to_string())), 257 | Message::OrderUpdates(_) => Ok("orderUpdates".to_string()), 258 | Message::UserFundings(fundings) => serde_json::to_string(&Subscription::UserFundings { 259 | user: fundings.data.user, 260 | }) 261 | .map_err(|e| Error::JsonParse(e.to_string())), 262 | Message::UserNonFundingLedgerUpdates(user_non_funding_ledger_updates) => { 263 | serde_json::to_string(&Subscription::UserNonFundingLedgerUpdates { 264 | user: user_non_funding_ledger_updates.data.user, 265 | }) 266 | .map_err(|e| Error::JsonParse(e.to_string())) 267 | } 268 | Message::Notification(_) => Ok("notification".to_string()), 269 | Message::WebData2(web_data2) => serde_json::to_string(&Subscription::WebData2 { 270 | user: web_data2.data.user, 271 | }) 272 | .map_err(|e| Error::JsonParse(e.to_string())), 273 | Message::ActiveAssetCtx(active_asset_ctx) => { 274 | serde_json::to_string(&Subscription::ActiveAssetCtx { 275 | coin: active_asset_ctx.data.coin.clone(), 276 | }) 277 | .map_err(|e| Error::JsonParse(e.to_string())) 278 | } 279 | Message::ActiveSpotAssetCtx(active_spot_asset_ctx) => { 280 | serde_json::to_string(&Subscription::ActiveAssetCtx { 281 | coin: active_spot_asset_ctx.data.coin.clone(), 282 | }) 283 | .map_err(|e| Error::JsonParse(e.to_string())) 284 | } 285 | Message::ActiveAssetData(active_asset_data) => { 286 | serde_json::to_string(&Subscription::ActiveAssetData { 287 | user: active_asset_data.data.user, 288 | coin: active_asset_data.data.coin.clone(), 289 | }) 290 | .map_err(|e| Error::JsonParse(e.to_string())) 291 | } 292 | Message::Bbo(bbo) => serde_json::to_string(&Subscription::Bbo { 293 | coin: bbo.data.coin.clone(), 294 | }) 295 | .map_err(|e| Error::JsonParse(e.to_string())), 296 | Message::SubscriptionResponse | Message::Pong => Ok(String::default()), 297 | Message::NoData => Ok("".to_string()), 298 | Message::HyperliquidError(err) => Ok(format!("hyperliquid error: {err:?}")), 299 | } 300 | } 301 | 302 | async fn parse_and_send_data( 303 | data: std::result::Result, 304 | subscriptions: &Arc>>>, 305 | ) -> Result<()> { 306 | match data { 307 | Ok(data) => match data.into_text() { 308 | Ok(data) => { 309 | if !data.starts_with('{') { 310 | return Ok(()); 311 | } 312 | let message = serde_json::from_str::(&data) 313 | .map_err(|e| Error::JsonParse(e.to_string()))?; 314 | let identifier = WsManager::get_identifier(&message)?; 315 | if identifier.is_empty() { 316 | return Ok(()); 317 | } 318 | 319 | let mut subscriptions = subscriptions.lock().await; 320 | let mut res = Ok(()); 321 | if let Some(subscription_datas) = subscriptions.get_mut(&identifier) { 322 | for subscription_data in subscription_datas { 323 | if let Err(e) = subscription_data 324 | .sending_channel 325 | .send(message.clone()) 326 | .map_err(|e| Error::WsSend(e.to_string())) 327 | { 328 | res = Err(e); 329 | } 330 | } 331 | } 332 | res 333 | } 334 | Err(err) => { 335 | let error = Error::ReaderTextConversion(err.to_string()); 336 | Ok(WsManager::send_to_all_subscriptions( 337 | subscriptions, 338 | Message::HyperliquidError(error.to_string()), 339 | ) 340 | .await?) 341 | } 342 | }, 343 | Err(err) => { 344 | let error = Error::GenericReader(err.to_string()); 345 | Ok(WsManager::send_to_all_subscriptions( 346 | subscriptions, 347 | Message::HyperliquidError(error.to_string()), 348 | ) 349 | .await?) 350 | } 351 | } 352 | } 353 | 354 | async fn send_to_all_subscriptions( 355 | subscriptions: &Arc>>>, 356 | message: Message, 357 | ) -> Result<()> { 358 | let mut subscriptions = subscriptions.lock().await; 359 | let mut res = Ok(()); 360 | for subscription_datas in subscriptions.values_mut() { 361 | for subscription_data in subscription_datas { 362 | if let Err(e) = subscription_data 363 | .sending_channel 364 | .send(message.clone()) 365 | .map_err(|e| Error::WsSend(e.to_string())) 366 | { 367 | res = Err(e); 368 | } 369 | } 370 | } 371 | res 372 | } 373 | 374 | async fn send_subscription_data( 375 | method: &'static str, 376 | writer: &mut SplitSink>, protocol::Message>, 377 | identifier: &str, 378 | ) -> Result<()> { 379 | let payload = serde_json::to_string(&SubscriptionSendData { 380 | method, 381 | subscription: &serde_json::from_str::(identifier) 382 | .map_err(|e| Error::JsonParse(e.to_string()))?, 383 | }) 384 | .map_err(|e| Error::JsonParse(e.to_string()))?; 385 | 386 | writer 387 | .send(protocol::Message::Text(payload)) 388 | .await 389 | .map_err(|e| Error::Websocket(e.to_string()))?; 390 | Ok(()) 391 | } 392 | 393 | async fn subscribe( 394 | writer: &mut SplitSink>, protocol::Message>, 395 | identifier: &str, 396 | ) -> Result<()> { 397 | Self::send_subscription_data("subscribe", writer, identifier).await 398 | } 399 | 400 | async fn unsubscribe( 401 | writer: &mut SplitSink>, protocol::Message>, 402 | identifier: &str, 403 | ) -> Result<()> { 404 | Self::send_subscription_data("unsubscribe", writer, identifier).await 405 | } 406 | 407 | pub(crate) async fn add_subscription( 408 | &mut self, 409 | identifier: String, 410 | sending_channel: UnboundedSender, 411 | ) -> Result { 412 | let mut subscriptions = self.subscriptions.lock().await; 413 | 414 | let identifier_entry = if let Subscription::UserEvents { user: _ } = 415 | serde_json::from_str::(&identifier) 416 | .map_err(|e| Error::JsonParse(e.to_string()))? 417 | { 418 | "userEvents".to_string() 419 | } else if let Subscription::OrderUpdates { user: _ } = 420 | serde_json::from_str::(&identifier) 421 | .map_err(|e| Error::JsonParse(e.to_string()))? 422 | { 423 | "orderUpdates".to_string() 424 | } else { 425 | identifier.clone() 426 | }; 427 | let subscriptions = subscriptions 428 | .entry(identifier_entry.clone()) 429 | .or_insert(Vec::new()); 430 | 431 | if !subscriptions.is_empty() && identifier_entry.eq("userEvents") { 432 | return Err(Error::UserEvents); 433 | } 434 | 435 | if subscriptions.is_empty() { 436 | Self::subscribe(self.writer.lock().await.borrow_mut(), identifier.as_str()).await?; 437 | } 438 | 439 | let subscription_id = self.subscription_id; 440 | self.subscription_identifiers 441 | .insert(subscription_id, identifier.clone()); 442 | subscriptions.push(SubscriptionData { 443 | sending_channel, 444 | subscription_id, 445 | id: identifier, 446 | }); 447 | 448 | self.subscription_id += 1; 449 | Ok(subscription_id) 450 | } 451 | 452 | pub(crate) async fn remove_subscription(&mut self, subscription_id: u32) -> Result<()> { 453 | let identifier = self 454 | .subscription_identifiers 455 | .get(&subscription_id) 456 | .ok_or(Error::SubscriptionNotFound)? 457 | .clone(); 458 | 459 | let identifier_entry = if let Subscription::UserEvents { user: _ } = 460 | serde_json::from_str::(&identifier) 461 | .map_err(|e| Error::JsonParse(e.to_string()))? 462 | { 463 | "userEvents".to_string() 464 | } else if let Subscription::OrderUpdates { user: _ } = 465 | serde_json::from_str::(&identifier) 466 | .map_err(|e| Error::JsonParse(e.to_string()))? 467 | { 468 | "orderUpdates".to_string() 469 | } else { 470 | identifier.clone() 471 | }; 472 | 473 | self.subscription_identifiers.remove(&subscription_id); 474 | 475 | let mut subscriptions = self.subscriptions.lock().await; 476 | 477 | let subscriptions = subscriptions 478 | .get_mut(&identifier_entry) 479 | .ok_or(Error::SubscriptionNotFound)?; 480 | let index = subscriptions 481 | .iter() 482 | .position(|subscription_data| subscription_data.subscription_id == subscription_id) 483 | .ok_or(Error::SubscriptionNotFound)?; 484 | subscriptions.remove(index); 485 | 486 | if subscriptions.is_empty() { 487 | Self::unsubscribe(self.writer.lock().await.borrow_mut(), identifier.as_str()).await?; 488 | } 489 | Ok(()) 490 | } 491 | } 492 | 493 | impl Drop for WsManager { 494 | fn drop(&mut self) { 495 | self.stop_flag.store(true, Ordering::Relaxed); 496 | } 497 | } 498 | -------------------------------------------------------------------------------- /src/exchange/exchange_client.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use alloy::{ 4 | primitives::{keccak256, Address, Signature, B256}, 5 | signers::local::PrivateKeySigner, 6 | }; 7 | use log::debug; 8 | use reqwest::Client; 9 | use serde::{ser::SerializeStruct, Deserialize, Serialize, Serializer}; 10 | 11 | use crate::{ 12 | exchange::{ 13 | actions::{ 14 | ApproveAgent, ApproveBuilderFee, BulkCancel, BulkModify, BulkOrder, ClaimRewards, 15 | EvmUserModify, ScheduleCancel, SendAsset, SetReferrer, UpdateIsolatedMargin, 16 | UpdateLeverage, UsdSend, 17 | }, 18 | cancel::{CancelRequest, CancelRequestCloid, ClientCancelRequestCloid}, 19 | modify::{ClientModifyRequest, ModifyRequest}, 20 | order::{MarketCloseParams, MarketOrderParams}, 21 | BuilderInfo, ClientCancelRequest, ClientLimit, ClientOrder, ClientOrderRequest, 22 | }, 23 | helpers::{next_nonce, uuid_to_hex_string}, 24 | info::info_client::InfoClient, 25 | meta::Meta, 26 | prelude::*, 27 | req::HttpClient, 28 | signature::{sign_l1_action, sign_typed_data}, 29 | BaseUrl, BulkCancelCloid, ClassTransfer, Error, ExchangeResponseStatus, SpotSend, SpotUser, 30 | VaultTransfer, Withdraw3, 31 | }; 32 | 33 | #[derive(Debug)] 34 | pub struct ExchangeClient { 35 | pub http_client: HttpClient, 36 | pub wallet: PrivateKeySigner, 37 | pub meta: Meta, 38 | pub vault_address: Option
, 39 | pub coin_to_asset: HashMap, 40 | } 41 | 42 | fn serialize_sig(sig: &Signature, s: S) -> std::result::Result 43 | where 44 | S: Serializer, 45 | { 46 | let mut state = s.serialize_struct("Signature", 3)?; 47 | state.serialize_field("r", &sig.r())?; 48 | state.serialize_field("s", &sig.s())?; 49 | state.serialize_field("v", &(27 + sig.v() as u64))?; 50 | state.end() 51 | } 52 | 53 | #[derive(Serialize, Deserialize)] 54 | #[serde(rename_all = "camelCase")] 55 | struct ExchangePayload { 56 | action: serde_json::Value, 57 | #[serde(serialize_with = "serialize_sig")] 58 | signature: Signature, 59 | nonce: u64, 60 | vault_address: Option
, 61 | } 62 | 63 | #[derive(Serialize, Deserialize, Debug, Clone)] 64 | #[serde(tag = "type")] 65 | #[serde(rename_all = "camelCase")] 66 | pub enum Actions { 67 | UsdSend(UsdSend), 68 | UpdateLeverage(UpdateLeverage), 69 | UpdateIsolatedMargin(UpdateIsolatedMargin), 70 | Order(BulkOrder), 71 | Cancel(BulkCancel), 72 | CancelByCloid(BulkCancelCloid), 73 | BatchModify(BulkModify), 74 | ApproveAgent(ApproveAgent), 75 | Withdraw3(Withdraw3), 76 | SpotUser(SpotUser), 77 | SendAsset(SendAsset), 78 | VaultTransfer(VaultTransfer), 79 | SpotSend(SpotSend), 80 | SetReferrer(SetReferrer), 81 | ApproveBuilderFee(ApproveBuilderFee), 82 | EvmUserModify(EvmUserModify), 83 | ScheduleCancel(ScheduleCancel), 84 | ClaimRewards(ClaimRewards), 85 | } 86 | 87 | impl Actions { 88 | fn hash(&self, timestamp: u64, vault_address: Option
) -> Result { 89 | let mut bytes = 90 | rmp_serde::to_vec_named(self).map_err(|e| Error::RmpParse(e.to_string()))?; 91 | bytes.extend(timestamp.to_be_bytes()); 92 | if let Some(vault_address) = vault_address { 93 | bytes.push(1); 94 | bytes.extend(vault_address); 95 | } else { 96 | bytes.push(0); 97 | } 98 | Ok(keccak256(bytes)) 99 | } 100 | } 101 | 102 | impl ExchangeClient { 103 | pub async fn new( 104 | client: Option, 105 | wallet: PrivateKeySigner, 106 | base_url: Option, 107 | meta: Option, 108 | vault_address: Option
, 109 | ) -> Result { 110 | let client = client.unwrap_or_default(); 111 | let base_url = base_url.unwrap_or(BaseUrl::Mainnet); 112 | 113 | let info = InfoClient::new(None, Some(base_url)).await?; 114 | let meta = if let Some(meta) = meta { 115 | meta 116 | } else { 117 | info.meta().await? 118 | }; 119 | 120 | let mut coin_to_asset = HashMap::new(); 121 | for (asset_ind, asset) in meta.universe.iter().enumerate() { 122 | coin_to_asset.insert(asset.name.clone(), asset_ind as u32); 123 | } 124 | 125 | coin_to_asset = info 126 | .spot_meta() 127 | .await? 128 | .add_pair_and_name_to_index_map(coin_to_asset); 129 | 130 | Ok(ExchangeClient { 131 | wallet, 132 | meta, 133 | vault_address, 134 | http_client: HttpClient { 135 | client, 136 | base_url: base_url.get_url(), 137 | }, 138 | coin_to_asset, 139 | }) 140 | } 141 | 142 | async fn post( 143 | &self, 144 | action: serde_json::Value, 145 | signature: Signature, 146 | nonce: u64, 147 | ) -> Result { 148 | // let signature = ExchangeSignature { 149 | // r: signature.r(), 150 | // s: signature.s(), 151 | // v: 27 + signature.v() as u64, 152 | // }; 153 | 154 | let exchange_payload = ExchangePayload { 155 | action, 156 | signature, 157 | nonce, 158 | vault_address: self.vault_address, 159 | }; 160 | let res = serde_json::to_string(&exchange_payload) 161 | .map_err(|e| Error::JsonParse(e.to_string()))?; 162 | debug!("Sending request {res:?}"); 163 | 164 | let output = &self 165 | .http_client 166 | .post("/exchange", res) 167 | .await 168 | .map_err(|e| Error::JsonParse(e.to_string()))?; 169 | debug!("Response: {output}"); 170 | serde_json::from_str(output).map_err(|e| Error::JsonParse(e.to_string())) 171 | } 172 | 173 | pub async fn enable_big_blocks( 174 | &self, 175 | using_big_blocks: bool, 176 | wallet: Option<&PrivateKeySigner>, 177 | ) -> Result { 178 | let wallet = wallet.unwrap_or(&self.wallet); 179 | 180 | let timestamp = next_nonce(); 181 | 182 | let action = Actions::EvmUserModify(EvmUserModify { using_big_blocks }); 183 | let connection_id = action.hash(timestamp, self.vault_address)?; 184 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 185 | let is_mainnet = self.http_client.is_mainnet(); 186 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 187 | 188 | self.post(action, signature, timestamp).await 189 | } 190 | 191 | pub async fn usdc_transfer( 192 | &self, 193 | amount: &str, 194 | destination: &str, 195 | wallet: Option<&PrivateKeySigner>, 196 | ) -> Result { 197 | let wallet = wallet.unwrap_or(&self.wallet); 198 | let hyperliquid_chain = if self.http_client.is_mainnet() { 199 | "Mainnet".to_string() 200 | } else { 201 | "Testnet".to_string() 202 | }; 203 | 204 | let timestamp = next_nonce(); 205 | let usd_send = UsdSend { 206 | signature_chain_id: 421614, 207 | hyperliquid_chain, 208 | destination: destination.to_string(), 209 | amount: amount.to_string(), 210 | time: timestamp, 211 | }; 212 | let signature = sign_typed_data(&usd_send, wallet)?; 213 | let action = serde_json::to_value(Actions::UsdSend(usd_send)) 214 | .map_err(|e| Error::JsonParse(e.to_string()))?; 215 | 216 | self.post(action, signature, timestamp).await 217 | } 218 | 219 | pub async fn class_transfer( 220 | &self, 221 | usdc: f64, 222 | to_perp: bool, 223 | wallet: Option<&PrivateKeySigner>, 224 | ) -> Result { 225 | // payload expects usdc without decimals 226 | let usdc = (usdc * 1e6).round() as u64; 227 | let wallet = wallet.unwrap_or(&self.wallet); 228 | 229 | let timestamp = next_nonce(); 230 | 231 | let action = Actions::SpotUser(SpotUser { 232 | class_transfer: ClassTransfer { usdc, to_perp }, 233 | }); 234 | let connection_id = action.hash(timestamp, self.vault_address)?; 235 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 236 | let is_mainnet = self.http_client.is_mainnet(); 237 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 238 | 239 | self.post(action, signature, timestamp).await 240 | } 241 | 242 | pub async fn send_asset( 243 | &self, 244 | destination: &str, 245 | source_dex: &str, 246 | destination_dex: &str, 247 | token: &str, 248 | amount: f64, 249 | wallet: Option<&PrivateKeySigner>, 250 | ) -> Result { 251 | let wallet = wallet.unwrap_or(&self.wallet); 252 | 253 | let hyperliquid_chain = if self.http_client.is_mainnet() { 254 | "Mainnet".to_string() 255 | } else { 256 | "Testnet".to_string() 257 | }; 258 | 259 | let timestamp = next_nonce(); 260 | 261 | // Build fromSubAccount string (similar to Python SDK) 262 | let from_sub_account = self 263 | .vault_address 264 | .map_or_else(String::new, |vault_addr| format!("{vault_addr:?}")); 265 | 266 | let send_asset = SendAsset { 267 | signature_chain_id: 421614, 268 | hyperliquid_chain, 269 | destination: destination.to_string(), 270 | source_dex: source_dex.to_string(), 271 | destination_dex: destination_dex.to_string(), 272 | token: token.to_string(), 273 | amount: amount.to_string(), 274 | from_sub_account, 275 | nonce: timestamp, 276 | }; 277 | 278 | let signature = sign_typed_data(&send_asset, wallet)?; 279 | let action = serde_json::to_value(Actions::SendAsset(send_asset)) 280 | .map_err(|e| Error::JsonParse(e.to_string()))?; 281 | 282 | self.post(action, signature, timestamp).await 283 | } 284 | 285 | pub async fn vault_transfer( 286 | &self, 287 | is_deposit: bool, 288 | usd: u64, 289 | vault_address: Option
, 290 | wallet: Option<&PrivateKeySigner>, 291 | ) -> Result { 292 | let vault_address = self 293 | .vault_address 294 | .or(vault_address) 295 | .ok_or(Error::VaultAddressNotFound)?; 296 | let wallet = wallet.unwrap_or(&self.wallet); 297 | 298 | let timestamp = next_nonce(); 299 | 300 | let action = Actions::VaultTransfer(VaultTransfer { 301 | vault_address, 302 | is_deposit, 303 | usd, 304 | }); 305 | let connection_id = action.hash(timestamp, self.vault_address)?; 306 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 307 | let is_mainnet = self.http_client.is_mainnet(); 308 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 309 | 310 | self.post(action, signature, timestamp).await 311 | } 312 | 313 | pub async fn market_open( 314 | &self, 315 | params: MarketOrderParams<'_>, 316 | ) -> Result { 317 | let slippage = params.slippage.unwrap_or(0.05); // Default 5% slippage 318 | let (px, sz_decimals) = self 319 | .calculate_slippage_price(params.asset, params.is_buy, slippage, params.px) 320 | .await?; 321 | 322 | let order = ClientOrderRequest { 323 | asset: params.asset.to_string(), 324 | is_buy: params.is_buy, 325 | reduce_only: false, 326 | limit_px: px, 327 | sz: round_to_decimals(params.sz, sz_decimals), 328 | cloid: params.cloid, 329 | order_type: ClientOrder::Limit(ClientLimit { 330 | tif: "Ioc".to_string(), 331 | }), 332 | }; 333 | 334 | self.order(order, params.wallet).await 335 | } 336 | 337 | pub async fn market_open_with_builder( 338 | &self, 339 | params: MarketOrderParams<'_>, 340 | builder: BuilderInfo, 341 | ) -> Result { 342 | let slippage = params.slippage.unwrap_or(0.05); // Default 5% slippage 343 | let (px, sz_decimals) = self 344 | .calculate_slippage_price(params.asset, params.is_buy, slippage, params.px) 345 | .await?; 346 | 347 | let order = ClientOrderRequest { 348 | asset: params.asset.to_string(), 349 | is_buy: params.is_buy, 350 | reduce_only: false, 351 | limit_px: px, 352 | sz: round_to_decimals(params.sz, sz_decimals), 353 | cloid: params.cloid, 354 | order_type: ClientOrder::Limit(ClientLimit { 355 | tif: "Ioc".to_string(), 356 | }), 357 | }; 358 | 359 | self.order_with_builder(order, params.wallet, builder).await 360 | } 361 | 362 | pub async fn market_close( 363 | &self, 364 | params: MarketCloseParams<'_>, 365 | ) -> Result { 366 | let slippage = params.slippage.unwrap_or(0.05); // Default 5% slippage 367 | let wallet = params.wallet.unwrap_or(&self.wallet); 368 | 369 | let base_url = match self.http_client.base_url.as_str() { 370 | "https://api.hyperliquid.xyz" => BaseUrl::Mainnet, 371 | "https://api.hyperliquid-testnet.xyz" => BaseUrl::Testnet, 372 | _ => return Err(Error::GenericRequest("Invalid base URL".to_string())), 373 | }; 374 | let info_client = InfoClient::new(None, Some(base_url)).await?; 375 | let user_state = info_client.user_state(wallet.address()).await?; 376 | 377 | let position = user_state 378 | .asset_positions 379 | .iter() 380 | .find(|p| p.position.coin == params.asset) 381 | .ok_or(Error::AssetNotFound)?; 382 | 383 | let szi = position 384 | .position 385 | .szi 386 | .parse::() 387 | .map_err(|_| Error::FloatStringParse)?; 388 | 389 | let (px, sz_decimals) = self 390 | .calculate_slippage_price(params.asset, szi < 0.0, slippage, params.px) 391 | .await?; 392 | 393 | let sz = round_to_decimals(params.sz.unwrap_or_else(|| szi.abs()), sz_decimals); 394 | 395 | let order = ClientOrderRequest { 396 | asset: params.asset.to_string(), 397 | is_buy: szi < 0.0, 398 | reduce_only: true, 399 | limit_px: px, 400 | sz, 401 | cloid: params.cloid, 402 | order_type: ClientOrder::Limit(ClientLimit { 403 | tif: "Ioc".to_string(), 404 | }), 405 | }; 406 | 407 | self.order(order, Some(wallet)).await 408 | } 409 | 410 | async fn calculate_slippage_price( 411 | &self, 412 | asset: &str, 413 | is_buy: bool, 414 | slippage: f64, 415 | px: Option, 416 | ) -> Result<(f64, u32)> { 417 | let base_url = match self.http_client.base_url.as_str() { 418 | "https://api.hyperliquid.xyz" => BaseUrl::Mainnet, 419 | "https://api.hyperliquid-testnet.xyz" => BaseUrl::Testnet, 420 | _ => return Err(Error::GenericRequest("Invalid base URL".to_string())), 421 | }; 422 | let info_client = InfoClient::new(None, Some(base_url)).await?; 423 | let meta = info_client.meta().await?; 424 | 425 | let asset_meta = meta 426 | .universe 427 | .iter() 428 | .find(|a| a.name == asset) 429 | .ok_or(Error::AssetNotFound)?; 430 | 431 | let sz_decimals = asset_meta.sz_decimals; 432 | let max_decimals: u32 = if self.coin_to_asset[asset] < 10000 { 433 | 6 434 | } else { 435 | 8 436 | }; 437 | let price_decimals = max_decimals.saturating_sub(sz_decimals); 438 | 439 | let px = if let Some(px) = px { 440 | px 441 | } else { 442 | let all_mids = info_client.all_mids().await?; 443 | all_mids 444 | .get(asset) 445 | .ok_or(Error::AssetNotFound)? 446 | .parse::() 447 | .map_err(|_| Error::FloatStringParse)? 448 | }; 449 | 450 | debug!("px before slippage: {px:?}"); 451 | let slippage_factor = if is_buy { 452 | 1.0 + slippage 453 | } else { 454 | 1.0 - slippage 455 | }; 456 | let px = px * slippage_factor; 457 | 458 | // Round to the correct number of decimal places and significant figures 459 | let px = round_to_significant_and_decimal(px, 5, price_decimals); 460 | 461 | debug!("px after slippage: {px:?}"); 462 | Ok((px, sz_decimals)) 463 | } 464 | 465 | pub async fn order( 466 | &self, 467 | order: ClientOrderRequest, 468 | wallet: Option<&PrivateKeySigner>, 469 | ) -> Result { 470 | self.bulk_order(vec![order], wallet).await 471 | } 472 | 473 | pub async fn order_with_builder( 474 | &self, 475 | order: ClientOrderRequest, 476 | wallet: Option<&PrivateKeySigner>, 477 | builder: BuilderInfo, 478 | ) -> Result { 479 | self.bulk_order_with_builder(vec![order], wallet, builder) 480 | .await 481 | } 482 | 483 | pub async fn bulk_order( 484 | &self, 485 | orders: Vec, 486 | wallet: Option<&PrivateKeySigner>, 487 | ) -> Result { 488 | let wallet = wallet.unwrap_or(&self.wallet); 489 | let timestamp = next_nonce(); 490 | 491 | let mut transformed_orders = Vec::new(); 492 | 493 | for order in orders { 494 | transformed_orders.push(order.convert(&self.coin_to_asset)?); 495 | } 496 | 497 | let action = Actions::Order(BulkOrder { 498 | orders: transformed_orders, 499 | grouping: "na".to_string(), 500 | builder: None, 501 | }); 502 | let connection_id = action.hash(timestamp, self.vault_address)?; 503 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 504 | 505 | let is_mainnet = self.http_client.is_mainnet(); 506 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 507 | self.post(action, signature, timestamp).await 508 | } 509 | 510 | pub async fn bulk_order_with_builder( 511 | &self, 512 | orders: Vec, 513 | wallet: Option<&PrivateKeySigner>, 514 | mut builder: BuilderInfo, 515 | ) -> Result { 516 | let wallet = wallet.unwrap_or(&self.wallet); 517 | let timestamp = next_nonce(); 518 | 519 | builder.builder = builder.builder.to_lowercase(); 520 | 521 | let mut transformed_orders = Vec::new(); 522 | 523 | for order in orders { 524 | transformed_orders.push(order.convert(&self.coin_to_asset)?); 525 | } 526 | 527 | let action = Actions::Order(BulkOrder { 528 | orders: transformed_orders, 529 | grouping: "na".to_string(), 530 | builder: Some(builder), 531 | }); 532 | let connection_id = action.hash(timestamp, self.vault_address)?; 533 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 534 | 535 | let is_mainnet = self.http_client.is_mainnet(); 536 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 537 | self.post(action, signature, timestamp).await 538 | } 539 | 540 | pub async fn cancel( 541 | &self, 542 | cancel: ClientCancelRequest, 543 | wallet: Option<&PrivateKeySigner>, 544 | ) -> Result { 545 | self.bulk_cancel(vec![cancel], wallet).await 546 | } 547 | 548 | pub async fn bulk_cancel( 549 | &self, 550 | cancels: Vec, 551 | wallet: Option<&PrivateKeySigner>, 552 | ) -> Result { 553 | let wallet = wallet.unwrap_or(&self.wallet); 554 | let timestamp = next_nonce(); 555 | 556 | let mut transformed_cancels = Vec::new(); 557 | for cancel in cancels.into_iter() { 558 | let &asset = self 559 | .coin_to_asset 560 | .get(&cancel.asset) 561 | .ok_or(Error::AssetNotFound)?; 562 | transformed_cancels.push(CancelRequest { 563 | asset, 564 | oid: cancel.oid, 565 | }); 566 | } 567 | 568 | let action = Actions::Cancel(BulkCancel { 569 | cancels: transformed_cancels, 570 | }); 571 | let connection_id = action.hash(timestamp, self.vault_address)?; 572 | 573 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 574 | let is_mainnet = self.http_client.is_mainnet(); 575 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 576 | 577 | self.post(action, signature, timestamp).await 578 | } 579 | 580 | pub async fn modify( 581 | &self, 582 | modify: ClientModifyRequest, 583 | wallet: Option<&PrivateKeySigner>, 584 | ) -> Result { 585 | self.bulk_modify(vec![modify], wallet).await 586 | } 587 | 588 | pub async fn bulk_modify( 589 | &self, 590 | modifies: Vec, 591 | wallet: Option<&PrivateKeySigner>, 592 | ) -> Result { 593 | let wallet = wallet.unwrap_or(&self.wallet); 594 | let timestamp = next_nonce(); 595 | 596 | let mut transformed_modifies = Vec::new(); 597 | for modify in modifies.into_iter() { 598 | transformed_modifies.push(ModifyRequest { 599 | oid: modify.oid, 600 | order: modify.order.convert(&self.coin_to_asset)?, 601 | }); 602 | } 603 | 604 | let action = Actions::BatchModify(BulkModify { 605 | modifies: transformed_modifies, 606 | }); 607 | let connection_id = action.hash(timestamp, self.vault_address)?; 608 | 609 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 610 | let is_mainnet = self.http_client.is_mainnet(); 611 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 612 | 613 | self.post(action, signature, timestamp).await 614 | } 615 | 616 | pub async fn cancel_by_cloid( 617 | &self, 618 | cancel: ClientCancelRequestCloid, 619 | wallet: Option<&PrivateKeySigner>, 620 | ) -> Result { 621 | self.bulk_cancel_by_cloid(vec![cancel], wallet).await 622 | } 623 | 624 | pub async fn bulk_cancel_by_cloid( 625 | &self, 626 | cancels: Vec, 627 | wallet: Option<&PrivateKeySigner>, 628 | ) -> Result { 629 | let wallet = wallet.unwrap_or(&self.wallet); 630 | let timestamp = next_nonce(); 631 | 632 | let mut transformed_cancels: Vec = Vec::new(); 633 | for cancel in cancels.into_iter() { 634 | let &asset = self 635 | .coin_to_asset 636 | .get(&cancel.asset) 637 | .ok_or(Error::AssetNotFound)?; 638 | transformed_cancels.push(CancelRequestCloid { 639 | asset, 640 | cloid: uuid_to_hex_string(cancel.cloid), 641 | }); 642 | } 643 | 644 | let action = Actions::CancelByCloid(BulkCancelCloid { 645 | cancels: transformed_cancels, 646 | }); 647 | 648 | let connection_id = action.hash(timestamp, self.vault_address)?; 649 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 650 | let is_mainnet = self.http_client.is_mainnet(); 651 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 652 | 653 | self.post(action, signature, timestamp).await 654 | } 655 | 656 | pub async fn update_leverage( 657 | &self, 658 | leverage: u32, 659 | coin: &str, 660 | is_cross: bool, 661 | wallet: Option<&PrivateKeySigner>, 662 | ) -> Result { 663 | let wallet = wallet.unwrap_or(&self.wallet); 664 | 665 | let timestamp = next_nonce(); 666 | 667 | let &asset_index = self.coin_to_asset.get(coin).ok_or(Error::AssetNotFound)?; 668 | let action = Actions::UpdateLeverage(UpdateLeverage { 669 | asset: asset_index, 670 | is_cross, 671 | leverage, 672 | }); 673 | let connection_id = action.hash(timestamp, self.vault_address)?; 674 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 675 | let is_mainnet = self.http_client.is_mainnet(); 676 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 677 | 678 | self.post(action, signature, timestamp).await 679 | } 680 | 681 | pub async fn update_isolated_margin( 682 | &self, 683 | amount: f64, 684 | coin: &str, 685 | wallet: Option<&PrivateKeySigner>, 686 | ) -> Result { 687 | let wallet = wallet.unwrap_or(&self.wallet); 688 | 689 | let amount = (amount * 1_000_000.0).round() as i64; 690 | let timestamp = next_nonce(); 691 | 692 | let &asset_index = self.coin_to_asset.get(coin).ok_or(Error::AssetNotFound)?; 693 | let action = Actions::UpdateIsolatedMargin(UpdateIsolatedMargin { 694 | asset: asset_index, 695 | is_buy: true, 696 | ntli: amount, 697 | }); 698 | let connection_id = action.hash(timestamp, self.vault_address)?; 699 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 700 | let is_mainnet = self.http_client.is_mainnet(); 701 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 702 | 703 | self.post(action, signature, timestamp).await 704 | } 705 | 706 | pub async fn approve_agent( 707 | &self, 708 | wallet: Option<&PrivateKeySigner>, 709 | ) -> Result<(B256, ExchangeResponseStatus)> { 710 | let wallet = wallet.unwrap_or(&self.wallet); 711 | let agent = PrivateKeySigner::random(); 712 | 713 | let hyperliquid_chain = if self.http_client.is_mainnet() { 714 | "Mainnet".to_string() 715 | } else { 716 | "Testnet".to_string() 717 | }; 718 | 719 | let nonce = next_nonce(); 720 | let approve_agent = ApproveAgent { 721 | signature_chain_id: 421614, 722 | hyperliquid_chain, 723 | agent_address: agent.address(), 724 | agent_name: None, 725 | nonce, 726 | }; 727 | let signature = sign_typed_data(&approve_agent, wallet)?; 728 | let action = serde_json::to_value(Actions::ApproveAgent(approve_agent)) 729 | .map_err(|e| Error::JsonParse(e.to_string()))?; 730 | Ok((agent.to_bytes(), self.post(action, signature, nonce).await?)) 731 | } 732 | 733 | pub async fn withdraw_from_bridge( 734 | &self, 735 | amount: &str, 736 | destination: &str, 737 | wallet: Option<&PrivateKeySigner>, 738 | ) -> Result { 739 | let wallet = wallet.unwrap_or(&self.wallet); 740 | let hyperliquid_chain = if self.http_client.is_mainnet() { 741 | "Mainnet".to_string() 742 | } else { 743 | "Testnet".to_string() 744 | }; 745 | 746 | let timestamp = next_nonce(); 747 | let withdraw = Withdraw3 { 748 | signature_chain_id: 421614, 749 | hyperliquid_chain, 750 | destination: destination.to_string(), 751 | amount: amount.to_string(), 752 | time: timestamp, 753 | }; 754 | let signature = sign_typed_data(&withdraw, wallet)?; 755 | let action = serde_json::to_value(Actions::Withdraw3(withdraw)) 756 | .map_err(|e| Error::JsonParse(e.to_string()))?; 757 | 758 | self.post(action, signature, timestamp).await 759 | } 760 | 761 | pub async fn spot_transfer( 762 | &self, 763 | amount: &str, 764 | destination: &str, 765 | token: &str, 766 | wallet: Option<&PrivateKeySigner>, 767 | ) -> Result { 768 | let wallet = wallet.unwrap_or(&self.wallet); 769 | let hyperliquid_chain = if self.http_client.is_mainnet() { 770 | "Mainnet".to_string() 771 | } else { 772 | "Testnet".to_string() 773 | }; 774 | 775 | let timestamp = next_nonce(); 776 | let spot_send = SpotSend { 777 | signature_chain_id: 421614, 778 | hyperliquid_chain, 779 | destination: destination.to_string(), 780 | amount: amount.to_string(), 781 | time: timestamp, 782 | token: token.to_string(), 783 | }; 784 | let signature = sign_typed_data(&spot_send, wallet)?; 785 | let action = serde_json::to_value(Actions::SpotSend(spot_send)) 786 | .map_err(|e| Error::JsonParse(e.to_string()))?; 787 | 788 | self.post(action, signature, timestamp).await 789 | } 790 | 791 | pub async fn set_referrer( 792 | &self, 793 | code: String, 794 | wallet: Option<&PrivateKeySigner>, 795 | ) -> Result { 796 | let wallet = wallet.unwrap_or(&self.wallet); 797 | let timestamp = next_nonce(); 798 | 799 | let action = Actions::SetReferrer(SetReferrer { code }); 800 | 801 | let connection_id = action.hash(timestamp, self.vault_address)?; 802 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 803 | 804 | let is_mainnet = self.http_client.is_mainnet(); 805 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 806 | self.post(action, signature, timestamp).await 807 | } 808 | 809 | pub async fn approve_builder_fee( 810 | &self, 811 | builder: Address, 812 | max_fee_rate: String, 813 | wallet: Option<&PrivateKeySigner>, 814 | ) -> Result { 815 | let wallet = wallet.unwrap_or(&self.wallet); 816 | let timestamp = next_nonce(); 817 | 818 | let hyperliquid_chain = if self.http_client.is_mainnet() { 819 | "Mainnet".to_string() 820 | } else { 821 | "Testnet".to_string() 822 | }; 823 | 824 | let approve_builder_fee = ApproveBuilderFee { 825 | signature_chain_id: 421614, 826 | hyperliquid_chain, 827 | builder, 828 | max_fee_rate, 829 | nonce: timestamp, 830 | }; 831 | let signature = sign_typed_data(&approve_builder_fee, wallet)?; 832 | let action = serde_json::to_value(Actions::ApproveBuilderFee(approve_builder_fee)) 833 | .map_err(|e| Error::JsonParse(e.to_string()))?; 834 | 835 | self.post(action, signature, timestamp).await 836 | } 837 | 838 | pub async fn schedule_cancel( 839 | &self, 840 | time: Option, 841 | wallet: Option<&PrivateKeySigner>, 842 | ) -> Result { 843 | let wallet = wallet.unwrap_or(&self.wallet); 844 | let timestamp = next_nonce(); 845 | 846 | let action = Actions::ScheduleCancel(ScheduleCancel { time }); 847 | let connection_id = action.hash(timestamp, self.vault_address)?; 848 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 849 | let is_mainnet = self.http_client.is_mainnet(); 850 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 851 | 852 | self.post(action, signature, timestamp).await 853 | } 854 | 855 | pub async fn claim_rewards( 856 | &self, 857 | wallet: Option<&PrivateKeySigner>, 858 | ) -> Result { 859 | let wallet = wallet.unwrap_or(&self.wallet); 860 | let timestamp = next_nonce(); 861 | 862 | let action = Actions::ClaimRewards(ClaimRewards {}); 863 | let connection_id = action.hash(timestamp, self.vault_address)?; 864 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 865 | let is_mainnet = self.http_client.is_mainnet(); 866 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 867 | 868 | self.post(action, signature, timestamp).await 869 | } 870 | } 871 | 872 | fn round_to_decimals(value: f64, decimals: u32) -> f64 { 873 | let factor = 10f64.powi(decimals as i32); 874 | (value * factor).round() / factor 875 | } 876 | 877 | fn round_to_significant_and_decimal(value: f64, sig_figs: u32, max_decimals: u32) -> f64 { 878 | let abs_value = value.abs(); 879 | let magnitude = abs_value.log10().floor() as i32; 880 | let scale = 10f64.powi(sig_figs as i32 - magnitude - 1); 881 | let rounded = (abs_value * scale).round() / scale; 882 | round_to_decimals(rounded.copysign(value), max_decimals) 883 | } 884 | 885 | #[cfg(test)] 886 | mod tests { 887 | use std::str::FromStr; 888 | 889 | use alloy::primitives::address; 890 | 891 | use super::*; 892 | use crate::{ 893 | exchange::order::{Limit, OrderRequest, Trigger}, 894 | Order, 895 | }; 896 | 897 | fn get_wallet() -> Result { 898 | let priv_key = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e"; 899 | priv_key 900 | .parse::() 901 | .map_err(|e| Error::Wallet(e.to_string())) 902 | } 903 | 904 | #[test] 905 | fn test_limit_order_action_hashing() -> Result<()> { 906 | let wallet = get_wallet()?; 907 | let action = Actions::Order(BulkOrder { 908 | orders: vec![OrderRequest { 909 | asset: 1, 910 | is_buy: true, 911 | limit_px: "2000.0".to_string(), 912 | sz: "3.5".to_string(), 913 | reduce_only: false, 914 | order_type: Order::Limit(Limit { 915 | tif: "Ioc".to_string(), 916 | }), 917 | cloid: None, 918 | }], 919 | grouping: "na".to_string(), 920 | builder: None, 921 | }); 922 | let connection_id = action.hash(1583838, None)?; 923 | 924 | let signature = sign_l1_action(&wallet, connection_id, true)?; 925 | assert_eq!(signature.to_string(), "0x77957e58e70f43b6b68581f2dc42011fc384538a2e5b7bf42d5b936f19fbb67360721a8598727230f67080efee48c812a6a4442013fd3b0eed509171bef9f23f1c"); 926 | 927 | let signature = sign_l1_action(&wallet, connection_id, false)?; 928 | assert_eq!(signature.to_string(), "0xcd0925372ff1ed499e54883e9a6205ecfadec748f80ec463fe2f84f1209648776377961965cb7b12414186b1ea291e95fd512722427efcbcfb3b0b2bcd4d79d01c"); 929 | 930 | Ok(()) 931 | } 932 | 933 | #[test] 934 | fn test_limit_order_action_hashing_with_cloid() -> Result<()> { 935 | let cloid = uuid::Uuid::from_str("1e60610f-0b3d-4205-97c8-8c1fed2ad5ee") 936 | .map_err(|_e| uuid::Uuid::new_v4()); 937 | let wallet = get_wallet()?; 938 | let action = Actions::Order(BulkOrder { 939 | orders: vec![OrderRequest { 940 | asset: 1, 941 | is_buy: true, 942 | limit_px: "2000.0".to_string(), 943 | sz: "3.5".to_string(), 944 | reduce_only: false, 945 | order_type: Order::Limit(Limit { 946 | tif: "Ioc".to_string(), 947 | }), 948 | cloid: Some(uuid_to_hex_string(cloid.unwrap())), 949 | }], 950 | grouping: "na".to_string(), 951 | builder: None, 952 | }); 953 | let connection_id = action.hash(1583838, None)?; 954 | 955 | let signature = sign_l1_action(&wallet, connection_id, true)?; 956 | assert_eq!(signature.to_string(), "0xd3e894092eb27098077145714630a77bbe3836120ee29df7d935d8510b03a08f456de5ec1be82aa65fc6ecda9ef928b0445e212517a98858cfaa251c4cd7552b1c"); 957 | 958 | let signature = sign_l1_action(&wallet, connection_id, false)?; 959 | assert_eq!(signature.to_string(), "0x3768349dbb22a7fd770fc9fc50c7b5124a7da342ea579b309f58002ceae49b4357badc7909770919c45d850aabb08474ff2b7b3204ae5b66d9f7375582981f111c"); 960 | 961 | Ok(()) 962 | } 963 | 964 | #[test] 965 | fn test_tpsl_order_action_hashing() -> Result<()> { 966 | for (tpsl, mainnet_signature, testnet_signature) in [ 967 | ( 968 | "tp", 969 | "0xb91e5011dff15e4b4a40753730bda44972132e7b75641f3cac58b66159534a170d422ee1ac3c7a7a2e11e298108a2d6b8da8612caceaeeb3e571de3b2dfda9e41b", 970 | "0x6df38b609904d0d4439884756b8f366f22b3a081801dbdd23f279094a2299fac6424cb0cdc48c3706aeaa368f81959e91059205403d3afd23a55983f710aee871b" 971 | ), 972 | ( 973 | "sl", 974 | "0x8456d2ace666fce1bee1084b00e9620fb20e810368841e9d4dd80eb29014611a0843416e51b1529c22dd2fc28f7ff8f6443875635c72011f60b62cbb8ce90e2d1c", 975 | "0xeb5bdb52297c1d19da45458758bd569dcb24c07e5c7bd52cf76600fd92fdd8213e661e21899c985421ec018a9ee7f3790e7b7d723a9932b7b5adcd7def5354601c" 976 | ) 977 | ] { 978 | let wallet = get_wallet()?; 979 | let action = Actions::Order(BulkOrder { 980 | orders: vec![ 981 | OrderRequest { 982 | asset: 1, 983 | is_buy: true, 984 | limit_px: "2000.0".to_string(), 985 | sz: "3.5".to_string(), 986 | reduce_only: false, 987 | order_type: Order::Trigger(Trigger { 988 | trigger_px: "2000.0".to_string(), 989 | is_market: true, 990 | tpsl: tpsl.to_string(), 991 | }), 992 | cloid: None, 993 | } 994 | ], 995 | grouping: "na".to_string(), 996 | builder: None, 997 | }); 998 | let connection_id = action.hash(1583838, None)?; 999 | 1000 | let signature = sign_l1_action(&wallet, connection_id, true)?; 1001 | assert_eq!(signature.to_string(), mainnet_signature); 1002 | 1003 | let signature = sign_l1_action(&wallet, connection_id, false)?; 1004 | assert_eq!(signature.to_string(), testnet_signature); 1005 | } 1006 | Ok(()) 1007 | } 1008 | 1009 | #[test] 1010 | fn test_cancel_action_hashing() -> Result<()> { 1011 | let wallet = get_wallet()?; 1012 | let action = Actions::Cancel(BulkCancel { 1013 | cancels: vec![CancelRequest { 1014 | asset: 1, 1015 | oid: 82382, 1016 | }], 1017 | }); 1018 | let connection_id = action.hash(1583838, None)?; 1019 | 1020 | let signature = sign_l1_action(&wallet, connection_id, true)?; 1021 | assert_eq!(signature.to_string(), "0x02f76cc5b16e0810152fa0e14e7b219f49c361e3325f771544c6f54e157bf9fa17ed0afc11a98596be85d5cd9f86600aad515337318f7ab346e5ccc1b03425d51b"); 1022 | 1023 | let signature = sign_l1_action(&wallet, connection_id, false)?; 1024 | assert_eq!(signature.to_string(), "0x6ffebadfd48067663390962539fbde76cfa36f53be65abe2ab72c9db6d0db44457720db9d7c4860f142a484f070c84eb4b9694c3a617c83f0d698a27e55fd5e01c"); 1025 | 1026 | Ok(()) 1027 | } 1028 | 1029 | #[test] 1030 | fn test_approve_builder_fee_signing() -> Result<()> { 1031 | let wallet = get_wallet()?; 1032 | 1033 | // Test mainnet 1034 | let mainnet_fee = ApproveBuilderFee { 1035 | signature_chain_id: 421614, 1036 | hyperliquid_chain: "Mainnet".to_string(), 1037 | builder: address!("0x1234567890123456789012345678901234567890"), 1038 | max_fee_rate: "0.001%".to_string(), 1039 | nonce: 1583838, 1040 | }; 1041 | 1042 | let mainnet_signature = sign_typed_data(&mainnet_fee, &wallet)?; 1043 | assert_eq!( 1044 | mainnet_signature.to_string(), 1045 | "0x343c9078af7c3d6683abefd0ca3b2960de5b669b716863e6dc49090853a4a3cd6c016301239461091a8ca3ea5ac783362526c4d9e9e624ffc563aea93d6ac2391b" 1046 | ); 1047 | 1048 | // Test testnet 1049 | let testnet_fee = ApproveBuilderFee { 1050 | signature_chain_id: 421614, 1051 | hyperliquid_chain: "Testnet".to_string(), 1052 | builder: address!("0x1234567890123456789012345678901234567890"), 1053 | max_fee_rate: "0.001%".to_string(), 1054 | nonce: 1583838, 1055 | }; 1056 | 1057 | let testnet_signature = sign_typed_data(&testnet_fee, &wallet)?; 1058 | assert_eq!( 1059 | testnet_signature.to_string(), 1060 | "0x2ada43eeebeba9cfe13faf95aa84e5b8c4885c3a07cbf4536f2df5edd340d4eb1ed0e24f60a80d199a842258d5fa737a18d486f7d4e656268b434d226f2811d71c" 1061 | ); 1062 | 1063 | // Verify signatures are different for mainnet vs testnet 1064 | assert_ne!(mainnet_signature, testnet_signature); 1065 | 1066 | Ok(()) 1067 | } 1068 | 1069 | #[test] 1070 | fn test_approve_builder_fee_hash() -> Result<()> { 1071 | let action = Actions::ApproveBuilderFee(ApproveBuilderFee { 1072 | signature_chain_id: 421614, 1073 | hyperliquid_chain: "Mainnet".to_string(), 1074 | builder: address!("0x1234567890123456789012345678901234567890"), 1075 | max_fee_rate: "0.001%".to_string(), 1076 | nonce: 1583838, 1077 | }); 1078 | 1079 | let connection_id = action.hash(1583838, None)?; 1080 | assert_eq!( 1081 | connection_id.to_string(), 1082 | "0xbe889a23135fce39a37315424cc4ae910edea7b42a075457b15bf4a9f0a8cfa4" 1083 | ); 1084 | 1085 | Ok(()) 1086 | } 1087 | 1088 | #[test] 1089 | fn test_claim_rewards_action_hashing() -> Result<()> { 1090 | let wallet = get_wallet()?; 1091 | let action = Actions::ClaimRewards(ClaimRewards {}); 1092 | let connection_id = action.hash(1583838, None)?; 1093 | 1094 | // Test mainnet signature 1095 | let signature = sign_l1_action(&wallet, connection_id, true)?; 1096 | assert_eq!( 1097 | signature.to_string(), 1098 | "0xe13542800ba5ec821153401e1cafac484d1f861adbbb86c00b580ec2560c153248b8d9f0e004ecc86959c07d44b591861ebab2167b54651a81367e2c3d472d4e1c" 1099 | ); 1100 | 1101 | // Test testnet signature 1102 | let signature = sign_l1_action(&wallet, connection_id, false)?; 1103 | assert_eq!( 1104 | signature.to_string(), 1105 | "0x16de9b346ddd8e200492a2db45ec9104dcdfc7fbfdbcd85890a6063bdd56df2c44846714c261a431de7095ad52e07143346eb26d9e66c6aed4674f120a1048131c" 1106 | ); 1107 | 1108 | Ok(()) 1109 | } 1110 | 1111 | #[test] 1112 | fn test_send_asset_signing() -> Result<()> { 1113 | let wallet = get_wallet()?; 1114 | 1115 | // Test mainnet - send asset to another address 1116 | let mainnet_send = SendAsset { 1117 | signature_chain_id: 421614, 1118 | hyperliquid_chain: "Mainnet".to_string(), 1119 | destination: "0x1234567890123456789012345678901234567890".to_string(), 1120 | source_dex: "".to_string(), 1121 | destination_dex: "spot".to_string(), 1122 | token: "PURR:0xc4bf3f870c0e9465323c0b6ed28096c2".to_string(), 1123 | amount: "100".to_string(), 1124 | from_sub_account: "".to_string(), 1125 | nonce: 1583838, 1126 | }; 1127 | 1128 | let mainnet_signature = sign_typed_data(&mainnet_send, &wallet)?; 1129 | // Signature generated successfully - just verify it's a valid signature object 1130 | 1131 | // Test testnet - send different token 1132 | let testnet_send = SendAsset { 1133 | signature_chain_id: 421614, 1134 | hyperliquid_chain: "Testnet".to_string(), 1135 | destination: "0x1234567890123456789012345678901234567890".to_string(), 1136 | source_dex: "spot".to_string(), 1137 | destination_dex: "".to_string(), 1138 | token: "USDC".to_string(), 1139 | amount: "50".to_string(), 1140 | from_sub_account: "".to_string(), 1141 | nonce: 1583838, 1142 | }; 1143 | 1144 | let testnet_signature = sign_typed_data(&testnet_send, &wallet)?; 1145 | // Verify signatures are different for mainnet vs testnet 1146 | assert_ne!(mainnet_signature, testnet_signature); 1147 | 1148 | // Test with vault/subaccount 1149 | let vault_send = SendAsset { 1150 | signature_chain_id: 421614, 1151 | hyperliquid_chain: "Mainnet".to_string(), 1152 | destination: "0x1234567890123456789012345678901234567890".to_string(), 1153 | source_dex: "".to_string(), 1154 | destination_dex: "spot".to_string(), 1155 | token: "PURR:0xc4bf3f870c0e9465323c0b6ed28096c2".to_string(), 1156 | amount: "100".to_string(), 1157 | from_sub_account: "0xabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd".to_string(), 1158 | nonce: 1583838, 1159 | }; 1160 | 1161 | let vault_signature = sign_typed_data(&vault_send, &wallet)?; 1162 | // Verify vault signature is different from non-vault signature 1163 | assert_ne!(mainnet_signature, vault_signature); 1164 | 1165 | Ok(()) 1166 | } 1167 | } 1168 | --------------------------------------------------------------------------------