├── .github └── workflows │ └── master.yml ├── .gitignore ├── Cargo.toml ├── LICENSE.md ├── README.md ├── ci.sh └── src ├── bin ├── agent.rs ├── approve_builder_fee.rs ├── bridge_withdraw.rs ├── class_transfer.rs ├── info.rs ├── leverage.rs ├── market_maker.rs ├── market_order_and_cancel.rs ├── market_order_with_builder_and_cancel.rs ├── order_and_cancel.rs ├── order_and_cancel_cloid.rs ├── order_with_builder_and_cancel.rs ├── set_referrer.rs ├── spot_order.rs ├── spot_transfer.rs ├── usdc_transfer.rs ├── vault_transfer.rs ├── ws_active_asset_ctx.rs ├── ws_all_mids.rs ├── ws_candles.rs ├── ws_l2_book.rs ├── ws_notification.rs ├── ws_orders.rs ├── ws_trades.rs ├── ws_user_events.rs ├── ws_user_fundings.rs ├── ws_user_non_funding_ledger_updates.rs └── ws_web_data2.rs ├── consts.rs ├── errors.rs ├── exchange ├── actions.rs ├── builder.rs ├── cancel.rs ├── exchange_client.rs ├── exchange_responses.rs ├── mod.rs ├── modify.rs └── order.rs ├── helpers.rs ├── info ├── info_client.rs ├── mod.rs ├── response_structs.rs └── sub_structs.rs ├── lib.rs ├── market_maker.rs ├── meta.rs ├── prelude.rs ├── proxy_digest.rs ├── req.rs ├── signature ├── agent.rs ├── create_signature.rs └── mod.rs └── ws ├── message_types.rs ├── mod.rs ├── sub_structs.rs └── ws_manager.rs /.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 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea/ 3 | Cargo.lock -------------------------------------------------------------------------------- /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 | chrono = "0.4.26" 16 | env_logger = "0.10.0" 17 | ethers = {version = "2.0.14", features = ["eip712", "abigen"]} 18 | futures-util = "0.3.28" 19 | hex = "0.4.3" 20 | http = "0.2.9" 21 | lazy_static = "1.3" 22 | log = "0.4.19" 23 | rand = "0.8.5" 24 | reqwest = "0.11.18" 25 | serde = {version = "1.0.175", features = ["derive"]} 26 | serde_json = "1.0.103" 27 | rmp-serde = "1.0.0" 28 | thiserror = "1.0.44" 29 | tokio = {version = "1.29.1", features = ["full"]} 30 | tokio-tungstenite = {version = "0.20.0", features = ["native-tls"]} 31 | uuid = {version = "1.6.1", features = ["v4"]} 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/bin/agent.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | 3 | use ethers::signers::{LocalWallet, Signer}; 4 | use hyperliquid_rust_sdk::{BaseUrl, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient}; 5 | 6 | #[tokio::main] 7 | async fn main() { 8 | env_logger::init(); 9 | // Key was randomly generated for testing and shouldn't be used with any real funds 10 | let wallet: LocalWallet = "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: LocalWallet = private_key.parse().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/bin/approve_builder_fee.rs: -------------------------------------------------------------------------------- 1 | use ethers::signers::LocalWallet; 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: LocalWallet = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 10 | .parse() 11 | .unwrap(); 12 | 13 | let exchange_client = 14 | ExchangeClient::new(None, wallet.clone(), Some(BaseUrl::Testnet), None, None) 15 | .await 16 | .unwrap(); 17 | 18 | let max_fee_rate = "0.1%"; 19 | let builder = "0x1ab189B7801140900C711E458212F9c76F8dAC79".to_lowercase(); 20 | 21 | let resp = exchange_client 22 | .approve_builder_fee(builder.to_string(), max_fee_rate.to_string(), Some(&wallet)) 23 | .await; 24 | info!("resp: {resp:#?}"); 25 | } 26 | -------------------------------------------------------------------------------- /src/bin/bridge_withdraw.rs: -------------------------------------------------------------------------------- 1 | use ethers::signers::LocalWallet; 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: LocalWallet = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 10 | .parse() 11 | .unwrap(); 12 | 13 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 14 | .await 15 | .unwrap(); 16 | 17 | let usd = "5"; // 5 USD 18 | let destination = "0x0D1d9635D0640821d15e323ac8AdADfA9c111414"; 19 | 20 | let res = exchange_client 21 | .withdraw_from_bridge(usd, destination, None) 22 | .await 23 | .unwrap(); 24 | info!("Withdraw from bridge result: {res:?}"); 25 | } 26 | -------------------------------------------------------------------------------- /src/bin/class_transfer.rs: -------------------------------------------------------------------------------- 1 | use ethers::signers::LocalWallet; 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: LocalWallet = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 10 | .parse() 11 | .unwrap(); 12 | 13 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 14 | .await 15 | .unwrap(); 16 | 17 | let usdc = 1.0; // 1 USD 18 | let to_perp = false; 19 | 20 | let res = exchange_client 21 | .class_transfer(usdc, to_perp, None) 22 | .await 23 | .unwrap(); 24 | info!("Class transfer result: {res:?}"); 25 | } 26 | -------------------------------------------------------------------------------- /src/bin/info.rs: -------------------------------------------------------------------------------- 1 | use ethers::types::H160; 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 | all_mids_example(&info_client).await; 17 | user_fills_example(&info_client).await; 18 | funding_history_example(&info_client).await; 19 | l2_snapshot_example(&info_client).await; 20 | candles_snapshot_example(&info_client).await; 21 | user_token_balances_example(&info_client).await; 22 | user_fees_example(&info_client).await; 23 | user_funding_example(&info_client).await; 24 | spot_meta_example(&info_client).await; 25 | spot_meta_and_asset_contexts_example(&info_client).await; 26 | query_order_by_oid_example(&info_client).await; 27 | query_referral_state_example(&info_client).await; 28 | historical_orders_example(&info_client).await; 29 | } 30 | 31 | fn address() -> H160 { 32 | ADDRESS.to_string().parse().unwrap() 33 | } 34 | 35 | async fn open_orders_example(info_client: &InfoClient) { 36 | let user = address(); 37 | 38 | info!( 39 | "Open order data for {user}: {:?}", 40 | info_client.open_orders(user).await.unwrap() 41 | ); 42 | } 43 | 44 | async fn user_state_example(info_client: &InfoClient) { 45 | let user = address(); 46 | 47 | info!( 48 | "User state data for {user}: {:?}", 49 | info_client.user_state(user).await.unwrap() 50 | ); 51 | } 52 | 53 | async fn user_states_example(info_client: &InfoClient) { 54 | let user = address(); 55 | 56 | info!( 57 | "User state data for {user}: {:?}", 58 | info_client.user_states(vec![user]).await.unwrap() 59 | ); 60 | } 61 | 62 | async fn user_token_balances_example(info_client: &InfoClient) { 63 | let user = address(); 64 | 65 | info!( 66 | "User token balances data for {user}: {:?}", 67 | info_client.user_token_balances(user).await.unwrap() 68 | ); 69 | } 70 | 71 | async fn user_fees_example(info_client: &InfoClient) { 72 | let user = address(); 73 | 74 | info!( 75 | "User fees data for {user}: {:?}", 76 | info_client.user_fees(user).await.unwrap() 77 | ); 78 | } 79 | 80 | async fn recent_trades(info_client: &InfoClient) { 81 | let coin = "ETH"; 82 | 83 | info!( 84 | "Recent trades for {coin}: {:?}", 85 | info_client.recent_trades(coin.to_string()).await.unwrap() 86 | ); 87 | } 88 | 89 | async fn meta_example(info_client: &InfoClient) { 90 | info!("Metadata: {:?}", info_client.meta().await.unwrap()); 91 | } 92 | 93 | async fn all_mids_example(info_client: &InfoClient) { 94 | info!("All mids: {:?}", info_client.all_mids().await.unwrap()); 95 | } 96 | 97 | async fn user_fills_example(info_client: &InfoClient) { 98 | let user = address(); 99 | 100 | info!( 101 | "User fills data for {user}: {:?}", 102 | info_client.user_fills(user).await.unwrap() 103 | ); 104 | } 105 | 106 | async fn funding_history_example(info_client: &InfoClient) { 107 | let coin = "ETH"; 108 | 109 | let start_timestamp = 1690540602225; 110 | let end_timestamp = 1690569402225; 111 | info!( 112 | "Funding data history for {coin} between timestamps {start_timestamp} and {end_timestamp}: {:?}", 113 | info_client.funding_history(coin.to_string(), start_timestamp, Some(end_timestamp)).await.unwrap() 114 | ); 115 | } 116 | 117 | async fn l2_snapshot_example(info_client: &InfoClient) { 118 | let coin = "ETH"; 119 | 120 | info!( 121 | "L2 snapshot data for {coin}: {:?}", 122 | info_client.l2_snapshot(coin.to_string()).await.unwrap() 123 | ); 124 | } 125 | 126 | async fn candles_snapshot_example(info_client: &InfoClient) { 127 | let coin = "ETH"; 128 | let start_timestamp = 1690540602225; 129 | let end_timestamp = 1690569402225; 130 | let interval = "1h"; 131 | 132 | info!( 133 | "Candles snapshot data for {coin} between timestamps {start_timestamp} and {end_timestamp} with interval {interval}: {:?}", 134 | info_client 135 | .candles_snapshot(coin.to_string(), interval.to_string(), start_timestamp, end_timestamp) 136 | .await 137 | .unwrap() 138 | ); 139 | } 140 | 141 | async fn user_funding_example(info_client: &InfoClient) { 142 | let user = address(); 143 | let start_timestamp = 1690540602225; 144 | let end_timestamp = 1690569402225; 145 | info!( 146 | "Funding data history for {user} between timestamps {start_timestamp} and {end_timestamp}: {:?}", 147 | info_client.user_funding_history(user, start_timestamp, Some(end_timestamp)).await.unwrap() 148 | ); 149 | } 150 | 151 | async fn spot_meta_example(info_client: &InfoClient) { 152 | info!("SpotMeta: {:?}", info_client.spot_meta().await.unwrap()); 153 | } 154 | 155 | async fn spot_meta_and_asset_contexts_example(info_client: &InfoClient) { 156 | info!( 157 | "SpotMetaAndAssetContexts: {:?}", 158 | info_client.spot_meta_and_asset_contexts().await.unwrap() 159 | ); 160 | } 161 | 162 | async fn query_order_by_oid_example(info_client: &InfoClient) { 163 | let user = address(); 164 | let oid = 26342632321; 165 | info!( 166 | "Order status for {user} for oid {oid}: {:?}", 167 | info_client.query_order_by_oid(user, oid).await.unwrap() 168 | ); 169 | } 170 | 171 | async fn query_referral_state_example(info_client: &InfoClient) { 172 | let user = address(); 173 | info!( 174 | "Referral state for {user}: {:?}", 175 | info_client.query_referral_state(user).await.unwrap() 176 | ); 177 | } 178 | 179 | async fn historical_orders_example(info_client: &InfoClient) { 180 | let user = address(); 181 | info!( 182 | "Historical orders for {user}: {:?}", 183 | info_client.historical_orders(user).await.unwrap() 184 | ); 185 | } 186 | -------------------------------------------------------------------------------- /src/bin/leverage.rs: -------------------------------------------------------------------------------- 1 | use ethers::signers::{LocalWallet, Signer}; 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: LocalWallet = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 11 | .parse() 12 | .unwrap(); 13 | 14 | let address = wallet.address(); 15 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 16 | .await 17 | .unwrap(); 18 | let info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 19 | 20 | let response = exchange_client 21 | .update_leverage(5, "ETH", false, None) 22 | .await 23 | .unwrap(); 24 | info!("Update leverage response: {response:?}"); 25 | 26 | let response = exchange_client 27 | .update_isolated_margin(1.0, "ETH", None) 28 | .await 29 | .unwrap(); 30 | 31 | info!("Update isolated margin response: {response:?}"); 32 | 33 | let user_state = info_client.user_state(address).await.unwrap(); 34 | info!("User state: {user_state:?}"); 35 | } 36 | -------------------------------------------------------------------------------- /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 ethers::signers::LocalWallet; 7 | 8 | use hyperliquid_rust_sdk::{MarketMaker, MarketMakerInput}; 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: LocalWallet = "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/market_order_and_cancel.rs: -------------------------------------------------------------------------------- 1 | use ethers::signers::LocalWallet; 2 | use log::info; 3 | 4 | use hyperliquid_rust_sdk::{ 5 | BaseUrl, ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, MarketCloseParams, 6 | MarketOrderParams, 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: LocalWallet = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 15 | .parse() 16 | .unwrap(); 17 | 18 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 19 | .await 20 | .unwrap(); 21 | 22 | // Market open order 23 | let market_open_params = MarketOrderParams { 24 | asset: "ETH", 25 | is_buy: true, 26 | sz: 0.01, 27 | px: None, 28 | slippage: Some(0.01), // 1% slippage 29 | cloid: None, 30 | wallet: None, 31 | }; 32 | 33 | let response = exchange_client 34 | .market_open(market_open_params) 35 | .await 36 | .unwrap(); 37 | info!("Market open order placed: {response:?}"); 38 | 39 | let response = match response { 40 | ExchangeResponseStatus::Ok(exchange_response) => exchange_response, 41 | ExchangeResponseStatus::Err(e) => panic!("Error with exchange response: {e}"), 42 | }; 43 | let status = response.data.unwrap().statuses[0].clone(); 44 | match status { 45 | ExchangeDataStatus::Filled(order) => info!("Order filled: {order:?}"), 46 | ExchangeDataStatus::Resting(order) => info!("Order resting: {order:?}"), 47 | _ => panic!("Unexpected status: {status:?}"), 48 | }; 49 | 50 | // Wait for a while before closing the position 51 | sleep(Duration::from_secs(10)); 52 | 53 | // Market close order 54 | let market_close_params = MarketCloseParams { 55 | asset: "ETH", 56 | sz: None, // Close entire position 57 | px: None, 58 | slippage: Some(0.01), // 1% slippage 59 | cloid: None, 60 | wallet: None, 61 | }; 62 | 63 | let response = exchange_client 64 | .market_close(market_close_params) 65 | .await 66 | .unwrap(); 67 | info!("Market close order placed: {response:?}"); 68 | 69 | let response = match response { 70 | ExchangeResponseStatus::Ok(exchange_response) => exchange_response, 71 | ExchangeResponseStatus::Err(e) => panic!("Error with exchange response: {e}"), 72 | }; 73 | let status = response.data.unwrap().statuses[0].clone(); 74 | match status { 75 | ExchangeDataStatus::Filled(order) => info!("Close order filled: {order:?}"), 76 | ExchangeDataStatus::Resting(order) => info!("Close order resting: {order:?}"), 77 | _ => panic!("Unexpected status: {status:?}"), 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /src/bin/market_order_with_builder_and_cancel.rs: -------------------------------------------------------------------------------- 1 | use ethers::signers::LocalWallet; 2 | use log::info; 3 | 4 | use hyperliquid_rust_sdk::{ 5 | BaseUrl, BuilderInfo, ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, 6 | MarketCloseParams, MarketOrderParams, 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: LocalWallet = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 15 | .parse() 16 | .unwrap(); 17 | 18 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 19 | .await 20 | .unwrap(); 21 | 22 | // Market open order 23 | let market_open_params = MarketOrderParams { 24 | asset: "ETH", 25 | is_buy: true, 26 | sz: 0.01, 27 | px: None, 28 | slippage: Some(0.01), // 1% slippage 29 | cloid: None, 30 | wallet: None, 31 | }; 32 | 33 | let fee = 1; 34 | let builder = "0x1ab189B7801140900C711E458212F9c76F8dAC79"; 35 | 36 | let response = exchange_client 37 | .market_open_with_builder( 38 | market_open_params, 39 | BuilderInfo { 40 | builder: builder.to_string(), 41 | fee, 42 | }, 43 | ) 44 | .await 45 | .unwrap(); 46 | info!("Market open order placed: {response:?}"); 47 | 48 | let response = match response { 49 | ExchangeResponseStatus::Ok(exchange_response) => exchange_response, 50 | ExchangeResponseStatus::Err(e) => panic!("Error with exchange response: {e}"), 51 | }; 52 | let status = response.data.unwrap().statuses[0].clone(); 53 | match status { 54 | ExchangeDataStatus::Filled(order) => info!("Order filled: {order:?}"), 55 | ExchangeDataStatus::Resting(order) => info!("Order resting: {order:?}"), 56 | _ => panic!("Unexpected status: {status:?}"), 57 | }; 58 | 59 | // Wait for a while before closing the position 60 | sleep(Duration::from_secs(10)); 61 | 62 | // Market close order 63 | let market_close_params = MarketCloseParams { 64 | asset: "ETH", 65 | sz: None, // Close entire position 66 | px: None, 67 | slippage: Some(0.01), // 1% slippage 68 | cloid: None, 69 | wallet: None, 70 | }; 71 | 72 | let response = exchange_client 73 | .market_close(market_close_params) 74 | .await 75 | .unwrap(); 76 | info!("Market close order placed: {response:?}"); 77 | 78 | let response = match response { 79 | ExchangeResponseStatus::Ok(exchange_response) => exchange_response, 80 | ExchangeResponseStatus::Err(e) => panic!("Error with exchange response: {e}"), 81 | }; 82 | let status = response.data.unwrap().statuses[0].clone(); 83 | match status { 84 | ExchangeDataStatus::Filled(order) => info!("Close order filled: {order:?}"), 85 | ExchangeDataStatus::Resting(order) => info!("Close order resting: {order:?}"), 86 | _ => panic!("Unexpected status: {status:?}"), 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /src/bin/order_and_cancel.rs: -------------------------------------------------------------------------------- 1 | use ethers::signers::LocalWallet; 2 | use log::info; 3 | 4 | use hyperliquid_rust_sdk::{ 5 | BaseUrl, ClientCancelRequest, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, 6 | ExchangeDataStatus, 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: LocalWallet = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 15 | .parse() 16 | .unwrap(); 17 | 18 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 19 | .await 20 | .unwrap(); 21 | 22 | let order = ClientOrderRequest { 23 | asset: "ETH".to_string(), 24 | is_buy: true, 25 | reduce_only: false, 26 | limit_px: 1800.0, 27 | sz: 0.01, 28 | cloid: None, 29 | order_type: ClientOrder::Limit(ClientLimit { 30 | tif: "Gtc".to_string(), 31 | }), 32 | }; 33 | 34 | let response = exchange_client.order(order, None).await.unwrap(); 35 | info!("Order placed: {response:?}"); 36 | 37 | let response = match response { 38 | ExchangeResponseStatus::Ok(exchange_response) => exchange_response, 39 | ExchangeResponseStatus::Err(e) => panic!("error with exchange response: {e}"), 40 | }; 41 | let status = response.data.unwrap().statuses[0].clone(); 42 | let oid = match status { 43 | ExchangeDataStatus::Filled(order) => order.oid, 44 | ExchangeDataStatus::Resting(order) => order.oid, 45 | _ => panic!("Error: {status:?}"), 46 | }; 47 | 48 | // So you can see the order before it's cancelled 49 | sleep(Duration::from_secs(10)); 50 | 51 | let cancel = ClientCancelRequest { 52 | asset: "ETH".to_string(), 53 | oid, 54 | }; 55 | 56 | // This response will return an error if order was filled (since you can't cancel a filled order), otherwise it will cancel the order 57 | let response = exchange_client.cancel(cancel, None).await.unwrap(); 58 | info!("Order potentially cancelled: {response:?}"); 59 | } 60 | -------------------------------------------------------------------------------- /src/bin/order_and_cancel_cloid.rs: -------------------------------------------------------------------------------- 1 | use ethers::signers::LocalWallet; 2 | use log::info; 3 | 4 | use hyperliquid_rust_sdk::{ 5 | BaseUrl, ClientCancelRequestCloid, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, 6 | }; 7 | use std::{thread::sleep, time::Duration}; 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: LocalWallet = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 15 | .parse() 16 | .unwrap(); 17 | 18 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 19 | .await 20 | .unwrap(); 21 | 22 | // Order and Cancel with cloid 23 | let cloid = Uuid::new_v4(); 24 | let order = ClientOrderRequest { 25 | asset: "ETH".to_string(), 26 | is_buy: true, 27 | reduce_only: false, 28 | limit_px: 1800.0, 29 | sz: 0.01, 30 | cloid: Some(cloid), 31 | order_type: ClientOrder::Limit(ClientLimit { 32 | tif: "Gtc".to_string(), 33 | }), 34 | }; 35 | 36 | let response = exchange_client.order(order, None).await.unwrap(); 37 | info!("Order placed: {response:?}"); 38 | 39 | // So you can see the order before it's cancelled 40 | sleep(Duration::from_secs(10)); 41 | 42 | let cancel = ClientCancelRequestCloid { 43 | asset: "ETH".to_string(), 44 | cloid, 45 | }; 46 | 47 | // This response will return an error if order was filled (since you can't cancel a filled order), otherwise it will cancel the order 48 | let response = exchange_client.cancel_by_cloid(cancel, None).await.unwrap(); 49 | info!("Order potentially cancelled: {response:?}"); 50 | } 51 | -------------------------------------------------------------------------------- /src/bin/order_with_builder_and_cancel.rs: -------------------------------------------------------------------------------- 1 | use ethers::signers::LocalWallet; 2 | use log::info; 3 | 4 | use hyperliquid_rust_sdk::{ 5 | BaseUrl, BuilderInfo, ClientCancelRequest, ClientLimit, ClientOrder, ClientOrderRequest, 6 | ExchangeClient, ExchangeDataStatus, 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: LocalWallet = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 15 | .parse() 16 | .unwrap(); 17 | 18 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 19 | .await 20 | .unwrap(); 21 | 22 | let order = ClientOrderRequest { 23 | asset: "ETH".to_string(), 24 | is_buy: true, 25 | reduce_only: false, 26 | limit_px: 1800.0, 27 | sz: 0.01, 28 | cloid: None, 29 | order_type: ClientOrder::Limit(ClientLimit { 30 | tif: "Gtc".to_string(), 31 | }), 32 | }; 33 | 34 | let fee = 1u64; 35 | let builder = "0x1ab189B7801140900C711E458212F9c76F8dAC79"; 36 | 37 | let response = exchange_client 38 | .order_with_builder( 39 | order, 40 | None, 41 | BuilderInfo { 42 | builder: builder.to_string(), 43 | fee, 44 | }, 45 | ) 46 | .await 47 | .unwrap(); 48 | info!("Order placed: {response:?}"); 49 | 50 | let response = match response { 51 | ExchangeResponseStatus::Ok(exchange_response) => exchange_response, 52 | ExchangeResponseStatus::Err(e) => panic!("error with exchange response: {e}"), 53 | }; 54 | let status = response.data.unwrap().statuses[0].clone(); 55 | let oid = match status { 56 | ExchangeDataStatus::Filled(order) => order.oid, 57 | ExchangeDataStatus::Resting(order) => order.oid, 58 | _ => panic!("Error: {status:?}"), 59 | }; 60 | 61 | // So you can see the order before it's cancelled 62 | sleep(Duration::from_secs(10)); 63 | 64 | let cancel = ClientCancelRequest { 65 | asset: "ETH".to_string(), 66 | oid, 67 | }; 68 | 69 | // This response will return an error if order was filled (since you can't cancel a filled order), otherwise it will cancel the order 70 | let response = exchange_client.cancel(cancel, None).await.unwrap(); 71 | info!("Order potentially cancelled: {response:?}"); 72 | } 73 | -------------------------------------------------------------------------------- /src/bin/set_referrer.rs: -------------------------------------------------------------------------------- 1 | use ethers::signers::LocalWallet; 2 | 3 | use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; 4 | use log::info; 5 | 6 | #[tokio::main] 7 | async fn main() { 8 | env_logger::init(); 9 | // Key was randomly generated for testing and shouldn't be used with any real funds 10 | let wallet: LocalWallet = "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/spot_order.rs: -------------------------------------------------------------------------------- 1 | use ethers::signers::LocalWallet; 2 | use log::info; 3 | 4 | use hyperliquid_rust_sdk::{ 5 | BaseUrl, ClientCancelRequest, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, 6 | ExchangeDataStatus, 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: LocalWallet = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 15 | .parse() 16 | .unwrap(); 17 | 18 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 19 | .await 20 | .unwrap(); 21 | 22 | let order = ClientOrderRequest { 23 | asset: "XYZTWO/USDC".to_string(), 24 | is_buy: true, 25 | reduce_only: false, 26 | limit_px: 0.00002378, 27 | sz: 1000000.0, 28 | cloid: None, 29 | order_type: ClientOrder::Limit(ClientLimit { 30 | tif: "Gtc".to_string(), 31 | }), 32 | }; 33 | 34 | let response = exchange_client.order(order, None).await.unwrap(); 35 | info!("Order placed: {response:?}"); 36 | 37 | let response = match response { 38 | ExchangeResponseStatus::Ok(exchange_response) => exchange_response, 39 | ExchangeResponseStatus::Err(e) => panic!("error with exchange response: {e}"), 40 | }; 41 | let status = response.data.unwrap().statuses[0].clone(); 42 | let oid = match status { 43 | ExchangeDataStatus::Filled(order) => order.oid, 44 | ExchangeDataStatus::Resting(order) => order.oid, 45 | _ => panic!("Error: {status:?}"), 46 | }; 47 | 48 | // So you can see the order before it's cancelled 49 | sleep(Duration::from_secs(10)); 50 | 51 | let cancel = ClientCancelRequest { 52 | asset: "HFUN/USDC".to_string(), 53 | oid, 54 | }; 55 | 56 | // This response will return an error if order was filled (since you can't cancel a filled order), otherwise it will cancel the order 57 | let response = exchange_client.cancel(cancel, None).await.unwrap(); 58 | info!("Order potentially cancelled: {response:?}"); 59 | } 60 | -------------------------------------------------------------------------------- /src/bin/spot_transfer.rs: -------------------------------------------------------------------------------- 1 | use ethers::signers::LocalWallet; 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: LocalWallet = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 10 | .parse() 11 | .unwrap(); 12 | 13 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 14 | .await 15 | .unwrap(); 16 | 17 | let amount = "1"; 18 | let destination = "0x0D1d9635D0640821d15e323ac8AdADfA9c111414"; 19 | let token = "PURR:0xc4bf3f870c0e9465323c0b6ed28096c2"; 20 | 21 | let res = exchange_client 22 | .spot_transfer(amount, destination, token, None) 23 | .await 24 | .unwrap(); 25 | info!("Spot transfer result: {res:?}"); 26 | } 27 | -------------------------------------------------------------------------------- /src/bin/usdc_transfer.rs: -------------------------------------------------------------------------------- 1 | use ethers::signers::LocalWallet; 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: LocalWallet = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 10 | .parse() 11 | .unwrap(); 12 | 13 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 14 | .await 15 | .unwrap(); 16 | 17 | let amount = "1"; // 1 USD 18 | let destination = "0x0D1d9635D0640821d15e323ac8AdADfA9c111414"; 19 | 20 | let res = exchange_client 21 | .usdc_transfer(amount, destination, None) 22 | .await 23 | .unwrap(); 24 | info!("Usdc transfer result: {res:?}"); 25 | } 26 | -------------------------------------------------------------------------------- /src/bin/vault_transfer.rs: -------------------------------------------------------------------------------- 1 | use ethers::signers::LocalWallet; 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: LocalWallet = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" 10 | .parse() 11 | .unwrap(); 12 | 13 | let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) 14 | .await 15 | .unwrap(); 16 | 17 | let usd = 5_000_000; // at least 5 USD 18 | let is_deposit = true; 19 | 20 | let res = exchange_client 21 | .vault_transfer( 22 | is_deposit, 23 | usd, 24 | Some( 25 | "0x1962905b0a2d0ce7907ae1a0d17f3e4a1f63dfb7" 26 | .parse() 27 | .unwrap(), 28 | ), 29 | None, 30 | ) 31 | .await 32 | .unwrap(); 33 | info!("Vault transfer result: {res:?}"); 34 | } 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/bin/ws_all_mids.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | 3 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 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 | 14 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 15 | 16 | let (sender, mut receiver) = unbounded_channel(); 17 | let subscription_id = info_client 18 | .subscribe(Subscription::AllMids, sender) 19 | .await 20 | .unwrap(); 21 | 22 | spawn(async move { 23 | sleep(Duration::from_secs(30)).await; 24 | info!("Unsubscribing from mids data"); 25 | info_client.unsubscribe(subscription_id).await.unwrap() 26 | }); 27 | 28 | // This loop ends when we unsubscribe 29 | while let Some(Message::AllMids(all_mids)) = receiver.recv().await { 30 | info!("Received mids data: {all_mids:?}"); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/bin/ws_candles.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | 3 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 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::Mainnet)).await.unwrap(); 14 | 15 | let (sender, mut receiver) = unbounded_channel(); 16 | let subscription_id = info_client 17 | .subscribe( 18 | Subscription::Candle { 19 | coin: "ETH".to_string(), 20 | interval: "1m".to_string(), 21 | }, 22 | sender, 23 | ) 24 | .await 25 | .unwrap(); 26 | 27 | spawn(async move { 28 | sleep(Duration::from_secs(300)).await; 29 | info!("Unsubscribing from candle data"); 30 | info_client.unsubscribe(subscription_id).await.unwrap() 31 | }); 32 | 33 | // This loop ends when we unsubscribe 34 | while let Some(Message::Candle(candle)) = receiver.recv().await { 35 | info!("Received candle data: {candle:?}"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/bin/ws_l2_book.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | 3 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 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 | 14 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 15 | 16 | let (sender, mut receiver) = unbounded_channel(); 17 | let subscription_id = info_client 18 | .subscribe( 19 | Subscription::L2Book { 20 | coin: "ETH".to_string(), 21 | }, 22 | sender, 23 | ) 24 | .await 25 | .unwrap(); 26 | 27 | spawn(async move { 28 | sleep(Duration::from_secs(30)).await; 29 | info!("Unsubscribing from l2 book data"); 30 | info_client.unsubscribe(subscription_id).await.unwrap() 31 | }); 32 | 33 | // This loop ends when we unsubscribe 34 | while let Some(Message::L2Book(l2_book)) = receiver.recv().await { 35 | info!("Received l2 book data: {l2_book:?}"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/bin/ws_notification.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | 3 | use std::str::FromStr; 4 | 5 | use ethers::types::H160; 6 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 7 | use tokio::{ 8 | spawn, 9 | sync::mpsc::unbounded_channel, 10 | time::{sleep, Duration}, 11 | }; 12 | 13 | #[tokio::main] 14 | async fn main() { 15 | env_logger::init(); 16 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 17 | let user = H160::from_str("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8").unwrap(); 18 | 19 | let (sender, mut receiver) = unbounded_channel(); 20 | let subscription_id = info_client 21 | .subscribe(Subscription::Notification { user }, sender) 22 | .await 23 | .unwrap(); 24 | 25 | spawn(async move { 26 | sleep(Duration::from_secs(30)).await; 27 | info!("Unsubscribing from notification data"); 28 | info_client.unsubscribe(subscription_id).await.unwrap() 29 | }); 30 | 31 | // this loop ends when we unsubscribe 32 | while let Some(Message::Notification(notification)) = receiver.recv().await { 33 | info!("Received notification data: {notification:?}"); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/bin/ws_orders.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | 3 | use std::str::FromStr; 4 | 5 | use ethers::types::H160; 6 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 7 | use tokio::{ 8 | spawn, 9 | sync::mpsc::unbounded_channel, 10 | time::{sleep, Duration}, 11 | }; 12 | 13 | #[tokio::main] 14 | async fn main() { 15 | env_logger::init(); 16 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 17 | let user = H160::from_str("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8").unwrap(); 18 | 19 | let (sender, mut receiver) = unbounded_channel(); 20 | let subscription_id = info_client 21 | .subscribe(Subscription::OrderUpdates { user }, sender) 22 | .await 23 | .unwrap(); 24 | 25 | spawn(async move { 26 | sleep(Duration::from_secs(30)).await; 27 | info!("Unsubscribing from order updates data"); 28 | info_client.unsubscribe(subscription_id).await.unwrap() 29 | }); 30 | 31 | // this loop ends when we unsubscribe 32 | while let Some(Message::OrderUpdates(order_updates)) = receiver.recv().await { 33 | info!("Received order update data: {order_updates:?}"); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/bin/ws_trades.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | 3 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 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 | 14 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 15 | 16 | let (sender, mut receiver) = unbounded_channel(); 17 | let subscription_id = info_client 18 | .subscribe( 19 | Subscription::Trades { 20 | coin: "ETH".to_string(), 21 | }, 22 | sender, 23 | ) 24 | .await 25 | .unwrap(); 26 | 27 | spawn(async move { 28 | sleep(Duration::from_secs(30)).await; 29 | info!("Unsubscribing from trades data"); 30 | info_client.unsubscribe(subscription_id).await.unwrap() 31 | }); 32 | 33 | // This loop ends when we unsubscribe 34 | while let Some(Message::Trades(trades)) = receiver.recv().await { 35 | info!("Received trade data: {trades:?}"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/bin/ws_user_events.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | 3 | use std::str::FromStr; 4 | 5 | use ethers::types::H160; 6 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 7 | use tokio::{ 8 | spawn, 9 | sync::mpsc::unbounded_channel, 10 | time::{sleep, Duration}, 11 | }; 12 | 13 | #[tokio::main] 14 | async fn main() { 15 | env_logger::init(); 16 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 17 | let user = H160::from_str("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8").unwrap(); 18 | 19 | let (sender, mut receiver) = unbounded_channel(); 20 | let subscription_id = info_client 21 | .subscribe(Subscription::UserEvents { user }, sender) 22 | .await 23 | .unwrap(); 24 | 25 | spawn(async move { 26 | sleep(Duration::from_secs(30)).await; 27 | info!("Unsubscribing from user events data"); 28 | info_client.unsubscribe(subscription_id).await.unwrap() 29 | }); 30 | 31 | // this loop ends when we unsubscribe 32 | while let Some(Message::User(user_event)) = receiver.recv().await { 33 | info!("Received user event data: {user_event:?}"); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/bin/ws_user_fundings.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | 3 | use std::str::FromStr; 4 | 5 | use ethers::types::H160; 6 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 7 | use tokio::{ 8 | spawn, 9 | sync::mpsc::unbounded_channel, 10 | time::{sleep, Duration}, 11 | }; 12 | 13 | #[tokio::main] 14 | async fn main() { 15 | env_logger::init(); 16 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 17 | let user = H160::from_str("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8").unwrap(); 18 | 19 | let (sender, mut receiver) = unbounded_channel(); 20 | let subscription_id = info_client 21 | .subscribe(Subscription::UserFundings { user }, sender) 22 | .await 23 | .unwrap(); 24 | 25 | spawn(async move { 26 | sleep(Duration::from_secs(30)).await; 27 | info!("Unsubscribing from user fundings data"); 28 | info_client.unsubscribe(subscription_id).await.unwrap() 29 | }); 30 | 31 | // this loop ends when we unsubscribe 32 | while let Some(Message::UserFundings(user_fundings)) = receiver.recv().await { 33 | info!("Received user fundings data: {user_fundings:?}"); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/bin/ws_user_non_funding_ledger_updates.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | 3 | use std::str::FromStr; 4 | 5 | use ethers::types::H160; 6 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 7 | use tokio::{ 8 | spawn, 9 | sync::mpsc::unbounded_channel, 10 | time::{sleep, Duration}, 11 | }; 12 | 13 | #[tokio::main] 14 | async fn main() { 15 | env_logger::init(); 16 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 17 | let user = H160::from_str("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8").unwrap(); 18 | 19 | let (sender, mut receiver) = unbounded_channel(); 20 | let subscription_id = info_client 21 | .subscribe(Subscription::UserNonFundingLedgerUpdates { user }, sender) 22 | .await 23 | .unwrap(); 24 | 25 | spawn(async move { 26 | sleep(Duration::from_secs(30)).await; 27 | info!("Unsubscribing from user non funding ledger update data"); 28 | info_client.unsubscribe(subscription_id).await.unwrap() 29 | }); 30 | 31 | // this loop ends when we unsubscribe 32 | while let Some(Message::UserNonFundingLedgerUpdates(user_non_funding_ledger_update)) = 33 | receiver.recv().await 34 | { 35 | info!("Received user non funding ledger update data: {user_non_funding_ledger_update:?}"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/bin/ws_web_data2.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | 3 | use std::str::FromStr; 4 | 5 | use ethers::types::H160; 6 | use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; 7 | use tokio::{ 8 | spawn, 9 | sync::mpsc::unbounded_channel, 10 | time::{sleep, Duration}, 11 | }; 12 | 13 | #[tokio::main] 14 | async fn main() { 15 | env_logger::init(); 16 | let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 17 | let user = H160::from_str("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8").unwrap(); 18 | 19 | let (sender, mut receiver) = unbounded_channel(); 20 | let subscription_id = info_client 21 | .subscribe(Subscription::WebData2 { user }, sender) 22 | .await 23 | .unwrap(); 24 | 25 | spawn(async move { 26 | sleep(Duration::from_secs(30)).await; 27 | info!("Unsubscribing from web data2"); 28 | info_client.unsubscribe(subscription_id).await.unwrap() 29 | }); 30 | 31 | // this loop ends when we unsubscribe 32 | while let Some(Message::WebData2(web_data2)) = receiver.recv().await { 33 | info!("Received web data: {web_data2:?}"); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /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/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/exchange/actions.rs: -------------------------------------------------------------------------------- 1 | use crate::exchange::{cancel::CancelRequest, modify::ModifyRequest, order::OrderRequest}; 2 | pub(crate) use ethers::{ 3 | abi::{encode, ParamType, Tokenizable}, 4 | types::{ 5 | transaction::{ 6 | eip712, 7 | eip712::{encode_eip712_type, EIP712Domain, Eip712, Eip712Error}, 8 | }, 9 | H160, U256, 10 | }, 11 | utils::keccak256, 12 | }; 13 | use serde::{Deserialize, Serialize}; 14 | 15 | use super::{cancel::CancelRequestCloid, BuilderInfo}; 16 | 17 | pub(crate) const HYPERLIQUID_EIP_PREFIX: &str = "HyperliquidTransaction:"; 18 | 19 | fn eip_712_domain(chain_id: U256) -> EIP712Domain { 20 | EIP712Domain { 21 | name: Some("HyperliquidSignTransaction".to_string()), 22 | version: Some("1".to_string()), 23 | chain_id: Some(chain_id), 24 | verifying_contract: Some( 25 | "0x0000000000000000000000000000000000000000" 26 | .parse() 27 | .unwrap(), 28 | ), 29 | salt: None, 30 | } 31 | } 32 | 33 | #[derive(Serialize, Deserialize, Debug, Clone)] 34 | #[serde(rename_all = "camelCase")] 35 | pub struct UsdSend { 36 | pub signature_chain_id: U256, 37 | pub hyperliquid_chain: String, 38 | pub destination: String, 39 | pub amount: String, 40 | pub time: u64, 41 | } 42 | 43 | impl Eip712 for UsdSend { 44 | type Error = Eip712Error; 45 | 46 | fn domain(&self) -> Result { 47 | Ok(eip_712_domain(self.signature_chain_id)) 48 | } 49 | 50 | fn type_hash() -> Result<[u8; 32], Self::Error> { 51 | Ok(eip712::make_type_hash( 52 | format!("{HYPERLIQUID_EIP_PREFIX}UsdSend"), 53 | &[ 54 | ("hyperliquidChain".to_string(), ParamType::String), 55 | ("destination".to_string(), ParamType::String), 56 | ("amount".to_string(), ParamType::String), 57 | ("time".to_string(), ParamType::Uint(64)), 58 | ], 59 | )) 60 | } 61 | 62 | fn struct_hash(&self) -> Result<[u8; 32], Self::Error> { 63 | let Self { 64 | signature_chain_id: _, 65 | hyperliquid_chain, 66 | destination, 67 | amount, 68 | time, 69 | } = self; 70 | let items = vec![ 71 | ethers::abi::Token::Uint(Self::type_hash()?.into()), 72 | encode_eip712_type(hyperliquid_chain.clone().into_token()), 73 | encode_eip712_type(destination.clone().into_token()), 74 | encode_eip712_type(amount.clone().into_token()), 75 | encode_eip712_type(time.into_token()), 76 | ]; 77 | Ok(keccak256(encode(&items))) 78 | } 79 | } 80 | 81 | #[derive(Serialize, Deserialize, Debug, Clone)] 82 | #[serde(rename_all = "camelCase")] 83 | pub struct UpdateLeverage { 84 | pub asset: u32, 85 | pub is_cross: bool, 86 | pub leverage: u32, 87 | } 88 | 89 | #[derive(Serialize, Deserialize, Debug, Clone)] 90 | #[serde(rename_all = "camelCase")] 91 | pub struct UpdateIsolatedMargin { 92 | pub asset: u32, 93 | pub is_buy: bool, 94 | pub ntli: i64, 95 | } 96 | 97 | #[derive(Serialize, Deserialize, Debug, Clone)] 98 | #[serde(rename_all = "camelCase")] 99 | pub struct BulkOrder { 100 | pub orders: Vec, 101 | pub grouping: String, 102 | #[serde(default, skip_serializing_if = "Option::is_none")] 103 | pub builder: Option, 104 | } 105 | 106 | #[derive(Serialize, Deserialize, Debug, Clone)] 107 | #[serde(rename_all = "camelCase")] 108 | pub struct BulkCancel { 109 | pub cancels: Vec, 110 | } 111 | 112 | #[derive(Serialize, Deserialize, Debug, Clone)] 113 | #[serde(rename_all = "camelCase")] 114 | pub struct BulkModify { 115 | pub modifies: Vec, 116 | } 117 | 118 | #[derive(Serialize, Deserialize, Debug, Clone)] 119 | #[serde(rename_all = "camelCase")] 120 | pub struct BulkCancelCloid { 121 | pub cancels: Vec, 122 | } 123 | 124 | #[derive(Serialize, Deserialize, Debug, Clone)] 125 | #[serde(rename_all = "camelCase")] 126 | pub struct ApproveAgent { 127 | pub signature_chain_id: U256, 128 | pub hyperliquid_chain: String, 129 | pub agent_address: H160, 130 | pub agent_name: Option, 131 | pub nonce: u64, 132 | } 133 | 134 | impl Eip712 for ApproveAgent { 135 | type Error = Eip712Error; 136 | 137 | fn domain(&self) -> Result { 138 | Ok(eip_712_domain(self.signature_chain_id)) 139 | } 140 | 141 | fn type_hash() -> Result<[u8; 32], Self::Error> { 142 | Ok(eip712::make_type_hash( 143 | format!("{HYPERLIQUID_EIP_PREFIX}ApproveAgent"), 144 | &[ 145 | ("hyperliquidChain".to_string(), ParamType::String), 146 | ("agentAddress".to_string(), ParamType::Address), 147 | ("agentName".to_string(), ParamType::String), 148 | ("nonce".to_string(), ParamType::Uint(64)), 149 | ], 150 | )) 151 | } 152 | 153 | fn struct_hash(&self) -> Result<[u8; 32], Self::Error> { 154 | let Self { 155 | signature_chain_id: _, 156 | hyperliquid_chain, 157 | agent_address, 158 | agent_name, 159 | nonce, 160 | } = self; 161 | let items = vec![ 162 | ethers::abi::Token::Uint(Self::type_hash()?.into()), 163 | encode_eip712_type(hyperliquid_chain.clone().into_token()), 164 | encode_eip712_type(agent_address.into_token()), 165 | encode_eip712_type(agent_name.clone().unwrap_or_default().into_token()), 166 | encode_eip712_type(nonce.into_token()), 167 | ]; 168 | Ok(keccak256(encode(&items))) 169 | } 170 | } 171 | 172 | #[derive(Serialize, Deserialize, Debug, Clone)] 173 | #[serde(rename_all = "camelCase")] 174 | pub struct Withdraw3 { 175 | pub hyperliquid_chain: String, 176 | pub signature_chain_id: U256, 177 | pub amount: String, 178 | pub time: u64, 179 | pub destination: String, 180 | } 181 | 182 | impl Eip712 for Withdraw3 { 183 | type Error = Eip712Error; 184 | 185 | fn domain(&self) -> Result { 186 | Ok(eip_712_domain(self.signature_chain_id)) 187 | } 188 | 189 | fn type_hash() -> Result<[u8; 32], Self::Error> { 190 | Ok(eip712::make_type_hash( 191 | format!("{HYPERLIQUID_EIP_PREFIX}Withdraw"), 192 | &[ 193 | ("hyperliquidChain".to_string(), ParamType::String), 194 | ("destination".to_string(), ParamType::String), 195 | ("amount".to_string(), ParamType::String), 196 | ("time".to_string(), ParamType::Uint(64)), 197 | ], 198 | )) 199 | } 200 | 201 | fn struct_hash(&self) -> Result<[u8; 32], Self::Error> { 202 | let Self { 203 | signature_chain_id: _, 204 | hyperliquid_chain, 205 | amount, 206 | time, 207 | destination, 208 | } = self; 209 | let items = vec![ 210 | ethers::abi::Token::Uint(Self::type_hash()?.into()), 211 | encode_eip712_type(hyperliquid_chain.clone().into_token()), 212 | encode_eip712_type(destination.clone().into_token()), 213 | encode_eip712_type(amount.clone().into_token()), 214 | encode_eip712_type(time.into_token()), 215 | ]; 216 | Ok(keccak256(encode(&items))) 217 | } 218 | } 219 | 220 | #[derive(Serialize, Deserialize, Debug, Clone)] 221 | #[serde(rename_all = "camelCase")] 222 | pub struct SpotSend { 223 | pub hyperliquid_chain: String, 224 | pub signature_chain_id: U256, 225 | pub destination: String, 226 | pub token: String, 227 | pub amount: String, 228 | pub time: u64, 229 | } 230 | 231 | impl Eip712 for SpotSend { 232 | type Error = Eip712Error; 233 | 234 | fn domain(&self) -> Result { 235 | Ok(eip_712_domain(self.signature_chain_id)) 236 | } 237 | 238 | fn type_hash() -> Result<[u8; 32], Self::Error> { 239 | Ok(eip712::make_type_hash( 240 | format!("{HYPERLIQUID_EIP_PREFIX}SpotSend"), 241 | &[ 242 | ("hyperliquidChain".to_string(), ParamType::String), 243 | ("destination".to_string(), ParamType::String), 244 | ("token".to_string(), ParamType::String), 245 | ("amount".to_string(), ParamType::String), 246 | ("time".to_string(), ParamType::Uint(64)), 247 | ], 248 | )) 249 | } 250 | 251 | fn struct_hash(&self) -> Result<[u8; 32], Self::Error> { 252 | let Self { 253 | signature_chain_id: _, 254 | hyperliquid_chain, 255 | destination, 256 | token, 257 | amount, 258 | time, 259 | } = self; 260 | let items = vec![ 261 | ethers::abi::Token::Uint(Self::type_hash()?.into()), 262 | encode_eip712_type(hyperliquid_chain.clone().into_token()), 263 | encode_eip712_type(destination.clone().into_token()), 264 | encode_eip712_type(token.clone().into_token()), 265 | encode_eip712_type(amount.clone().into_token()), 266 | encode_eip712_type(time.into_token()), 267 | ]; 268 | Ok(keccak256(encode(&items))) 269 | } 270 | } 271 | 272 | #[derive(Serialize, Deserialize, Debug, Clone)] 273 | #[serde(rename_all = "camelCase")] 274 | pub struct SpotUser { 275 | pub class_transfer: ClassTransfer, 276 | } 277 | 278 | #[derive(Serialize, Deserialize, Debug, Clone)] 279 | #[serde(rename_all = "camelCase")] 280 | pub struct ClassTransfer { 281 | pub usdc: u64, 282 | pub to_perp: bool, 283 | } 284 | 285 | #[derive(Serialize, Deserialize, Debug, Clone)] 286 | #[serde(rename_all = "camelCase")] 287 | pub struct VaultTransfer { 288 | pub vault_address: H160, 289 | pub is_deposit: bool, 290 | pub usd: u64, 291 | } 292 | 293 | #[derive(Serialize, Deserialize, Debug, Clone)] 294 | #[serde(rename_all = "camelCase")] 295 | pub struct SetReferrer { 296 | pub code: String, 297 | } 298 | 299 | #[derive(Serialize, Deserialize, Debug, Clone)] 300 | #[serde(rename_all = "camelCase")] 301 | pub struct ApproveBuilderFee { 302 | pub max_fee_rate: String, 303 | pub builder: String, 304 | pub nonce: u64, 305 | pub signature_chain_id: U256, 306 | pub hyperliquid_chain: String, 307 | } 308 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /src/exchange/exchange_client.rs: -------------------------------------------------------------------------------- 1 | use crate::signature::sign_typed_data; 2 | use crate::{ 3 | exchange::{ 4 | actions::{ 5 | ApproveAgent, ApproveBuilderFee, BulkCancel, BulkModify, BulkOrder, SetReferrer, 6 | UpdateIsolatedMargin, UpdateLeverage, UsdSend, 7 | }, 8 | cancel::{CancelRequest, CancelRequestCloid}, 9 | modify::{ClientModifyRequest, ModifyRequest}, 10 | ClientCancelRequest, ClientOrderRequest, 11 | }, 12 | helpers::{generate_random_key, next_nonce, uuid_to_hex_string}, 13 | info::info_client::InfoClient, 14 | meta::Meta, 15 | prelude::*, 16 | req::HttpClient, 17 | signature::sign_l1_action, 18 | BaseUrl, BulkCancelCloid, Error, ExchangeResponseStatus, 19 | }; 20 | use crate::{ClassTransfer, SpotSend, SpotUser, VaultTransfer, Withdraw3}; 21 | use ethers::{ 22 | abi::AbiEncode, 23 | signers::{LocalWallet, Signer}, 24 | types::{Signature, H160, H256}, 25 | }; 26 | use log::debug; 27 | use reqwest::Client; 28 | use serde::{Deserialize, Serialize}; 29 | use std::collections::HashMap; 30 | 31 | use super::cancel::ClientCancelRequestCloid; 32 | use super::order::{MarketCloseParams, MarketOrderParams}; 33 | use super::{BuilderInfo, ClientLimit, ClientOrder}; 34 | 35 | #[derive(Debug)] 36 | pub struct ExchangeClient { 37 | pub http_client: HttpClient, 38 | pub wallet: LocalWallet, 39 | pub meta: Meta, 40 | pub vault_address: Option, 41 | pub coin_to_asset: HashMap, 42 | } 43 | 44 | #[derive(Serialize, Deserialize)] 45 | #[serde(rename_all = "camelCase")] 46 | struct ExchangePayload { 47 | action: serde_json::Value, 48 | signature: Signature, 49 | nonce: u64, 50 | vault_address: Option, 51 | } 52 | 53 | #[derive(Serialize, Deserialize, Debug, Clone)] 54 | #[serde(tag = "type")] 55 | #[serde(rename_all = "camelCase")] 56 | pub enum Actions { 57 | UsdSend(UsdSend), 58 | UpdateLeverage(UpdateLeverage), 59 | UpdateIsolatedMargin(UpdateIsolatedMargin), 60 | Order(BulkOrder), 61 | Cancel(BulkCancel), 62 | CancelByCloid(BulkCancelCloid), 63 | BatchModify(BulkModify), 64 | ApproveAgent(ApproveAgent), 65 | Withdraw3(Withdraw3), 66 | SpotUser(SpotUser), 67 | VaultTransfer(VaultTransfer), 68 | SpotSend(SpotSend), 69 | SetReferrer(SetReferrer), 70 | ApproveBuilderFee(ApproveBuilderFee), 71 | } 72 | 73 | impl Actions { 74 | fn hash(&self, timestamp: u64, vault_address: Option) -> Result { 75 | let mut bytes = 76 | rmp_serde::to_vec_named(self).map_err(|e| Error::RmpParse(e.to_string()))?; 77 | bytes.extend(timestamp.to_be_bytes()); 78 | if let Some(vault_address) = vault_address { 79 | bytes.push(1); 80 | bytes.extend(vault_address.to_fixed_bytes()); 81 | } else { 82 | bytes.push(0); 83 | } 84 | Ok(H256(ethers::utils::keccak256(bytes))) 85 | } 86 | } 87 | 88 | impl ExchangeClient { 89 | pub async fn new( 90 | client: Option, 91 | wallet: LocalWallet, 92 | base_url: Option, 93 | meta: Option, 94 | vault_address: Option, 95 | ) -> Result { 96 | let client = client.unwrap_or_default(); 97 | let base_url = base_url.unwrap_or(BaseUrl::Mainnet); 98 | 99 | let info = InfoClient::new(None, Some(base_url)).await?; 100 | let meta = if let Some(meta) = meta { 101 | meta 102 | } else { 103 | info.meta().await? 104 | }; 105 | 106 | let mut coin_to_asset = HashMap::new(); 107 | for (asset_ind, asset) in meta.universe.iter().enumerate() { 108 | coin_to_asset.insert(asset.name.clone(), asset_ind as u32); 109 | } 110 | 111 | coin_to_asset = info 112 | .spot_meta() 113 | .await? 114 | .add_pair_and_name_to_index_map(coin_to_asset); 115 | 116 | Ok(ExchangeClient { 117 | wallet, 118 | meta, 119 | vault_address, 120 | http_client: HttpClient { 121 | client, 122 | base_url: base_url.get_url(), 123 | }, 124 | coin_to_asset, 125 | }) 126 | } 127 | 128 | async fn post( 129 | &self, 130 | action: serde_json::Value, 131 | signature: Signature, 132 | nonce: u64, 133 | ) -> Result { 134 | let exchange_payload = ExchangePayload { 135 | action, 136 | signature, 137 | nonce, 138 | vault_address: self.vault_address, 139 | }; 140 | let res = serde_json::to_string(&exchange_payload) 141 | .map_err(|e| Error::JsonParse(e.to_string()))?; 142 | debug!("Sending request {res:?}"); 143 | 144 | let output = &self 145 | .http_client 146 | .post("/exchange", res) 147 | .await 148 | .map_err(|e| Error::JsonParse(e.to_string()))?; 149 | serde_json::from_str(output).map_err(|e| Error::JsonParse(e.to_string())) 150 | } 151 | 152 | pub async fn usdc_transfer( 153 | &self, 154 | amount: &str, 155 | destination: &str, 156 | wallet: Option<&LocalWallet>, 157 | ) -> Result { 158 | let wallet = wallet.unwrap_or(&self.wallet); 159 | let hyperliquid_chain = if self.http_client.is_mainnet() { 160 | "Mainnet".to_string() 161 | } else { 162 | "Testnet".to_string() 163 | }; 164 | 165 | let timestamp = next_nonce(); 166 | let usd_send = UsdSend { 167 | signature_chain_id: 421614.into(), 168 | hyperliquid_chain, 169 | destination: destination.to_string(), 170 | amount: amount.to_string(), 171 | time: timestamp, 172 | }; 173 | let signature = sign_typed_data(&usd_send, wallet)?; 174 | let action = serde_json::to_value(Actions::UsdSend(usd_send)) 175 | .map_err(|e| Error::JsonParse(e.to_string()))?; 176 | 177 | self.post(action, signature, timestamp).await 178 | } 179 | 180 | pub async fn class_transfer( 181 | &self, 182 | usdc: f64, 183 | to_perp: bool, 184 | wallet: Option<&LocalWallet>, 185 | ) -> Result { 186 | // payload expects usdc without decimals 187 | let usdc = (usdc * 1e6).round() as u64; 188 | let wallet = wallet.unwrap_or(&self.wallet); 189 | 190 | let timestamp = next_nonce(); 191 | 192 | let action = Actions::SpotUser(SpotUser { 193 | class_transfer: ClassTransfer { usdc, to_perp }, 194 | }); 195 | let connection_id = action.hash(timestamp, self.vault_address)?; 196 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 197 | let is_mainnet = self.http_client.is_mainnet(); 198 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 199 | 200 | self.post(action, signature, timestamp).await 201 | } 202 | 203 | pub async fn vault_transfer( 204 | &self, 205 | is_deposit: bool, 206 | usd: u64, 207 | vault_address: Option, 208 | wallet: Option<&LocalWallet>, 209 | ) -> Result { 210 | let vault_address = self 211 | .vault_address 212 | .or(vault_address) 213 | .ok_or(Error::VaultAddressNotFound)?; 214 | let wallet = wallet.unwrap_or(&self.wallet); 215 | 216 | let timestamp = next_nonce(); 217 | 218 | let action = Actions::VaultTransfer(VaultTransfer { 219 | vault_address, 220 | is_deposit, 221 | usd, 222 | }); 223 | let connection_id = action.hash(timestamp, self.vault_address)?; 224 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 225 | let is_mainnet = self.http_client.is_mainnet(); 226 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 227 | 228 | self.post(action, signature, timestamp).await 229 | } 230 | 231 | pub async fn market_open( 232 | &self, 233 | params: MarketOrderParams<'_>, 234 | ) -> Result { 235 | let slippage = params.slippage.unwrap_or(0.05); // Default 5% slippage 236 | let (px, sz_decimals) = self 237 | .calculate_slippage_price(params.asset, params.is_buy, slippage, params.px) 238 | .await?; 239 | 240 | let order = ClientOrderRequest { 241 | asset: params.asset.to_string(), 242 | is_buy: params.is_buy, 243 | reduce_only: false, 244 | limit_px: px, 245 | sz: round_to_decimals(params.sz, sz_decimals), 246 | cloid: params.cloid, 247 | order_type: ClientOrder::Limit(ClientLimit { 248 | tif: "Ioc".to_string(), 249 | }), 250 | }; 251 | 252 | self.order(order, params.wallet).await 253 | } 254 | 255 | pub async fn market_open_with_builder( 256 | &self, 257 | params: MarketOrderParams<'_>, 258 | builder: BuilderInfo, 259 | ) -> Result { 260 | let slippage = params.slippage.unwrap_or(0.05); // Default 5% slippage 261 | let (px, sz_decimals) = self 262 | .calculate_slippage_price(params.asset, params.is_buy, slippage, params.px) 263 | .await?; 264 | 265 | let order = ClientOrderRequest { 266 | asset: params.asset.to_string(), 267 | is_buy: params.is_buy, 268 | reduce_only: false, 269 | limit_px: px, 270 | sz: round_to_decimals(params.sz, sz_decimals), 271 | cloid: params.cloid, 272 | order_type: ClientOrder::Limit(ClientLimit { 273 | tif: "Ioc".to_string(), 274 | }), 275 | }; 276 | 277 | self.order_with_builder(order, params.wallet, builder).await 278 | } 279 | 280 | pub async fn market_close( 281 | &self, 282 | params: MarketCloseParams<'_>, 283 | ) -> Result { 284 | let slippage = params.slippage.unwrap_or(0.05); // Default 5% slippage 285 | let wallet = params.wallet.unwrap_or(&self.wallet); 286 | 287 | let base_url = match self.http_client.base_url.as_str() { 288 | "https://api.hyperliquid.xyz" => BaseUrl::Mainnet, 289 | "https://api.hyperliquid-testnet.xyz" => BaseUrl::Testnet, 290 | _ => return Err(Error::GenericRequest("Invalid base URL".to_string())), 291 | }; 292 | let info_client = InfoClient::new(None, Some(base_url)).await?; 293 | let user_state = info_client.user_state(wallet.address()).await?; 294 | 295 | let position = user_state 296 | .asset_positions 297 | .iter() 298 | .find(|p| p.position.coin == params.asset) 299 | .ok_or(Error::AssetNotFound)?; 300 | 301 | let szi = position 302 | .position 303 | .szi 304 | .parse::() 305 | .map_err(|_| Error::FloatStringParse)?; 306 | 307 | let (px, sz_decimals) = self 308 | .calculate_slippage_price(params.asset, szi < 0.0, slippage, params.px) 309 | .await?; 310 | 311 | let sz = round_to_decimals(params.sz.unwrap_or_else(|| szi.abs()), sz_decimals); 312 | 313 | let order = ClientOrderRequest { 314 | asset: params.asset.to_string(), 315 | is_buy: szi < 0.0, 316 | reduce_only: true, 317 | limit_px: px, 318 | sz, 319 | cloid: params.cloid, 320 | order_type: ClientOrder::Limit(ClientLimit { 321 | tif: "Ioc".to_string(), 322 | }), 323 | }; 324 | 325 | self.order(order, Some(wallet)).await 326 | } 327 | 328 | async fn calculate_slippage_price( 329 | &self, 330 | asset: &str, 331 | is_buy: bool, 332 | slippage: f64, 333 | px: Option, 334 | ) -> Result<(f64, u32)> { 335 | let base_url = match self.http_client.base_url.as_str() { 336 | "https://api.hyperliquid.xyz" => BaseUrl::Mainnet, 337 | "https://api.hyperliquid-testnet.xyz" => BaseUrl::Testnet, 338 | _ => return Err(Error::GenericRequest("Invalid base URL".to_string())), 339 | }; 340 | let info_client = InfoClient::new(None, Some(base_url)).await?; 341 | let meta = info_client.meta().await?; 342 | 343 | let asset_meta = meta 344 | .universe 345 | .iter() 346 | .find(|a| a.name == asset) 347 | .ok_or(Error::AssetNotFound)?; 348 | 349 | let sz_decimals = asset_meta.sz_decimals; 350 | let max_decimals: u32 = if self.coin_to_asset[asset] < 10000 { 351 | 6 352 | } else { 353 | 8 354 | }; 355 | let price_decimals = max_decimals.saturating_sub(sz_decimals); 356 | 357 | let px = if let Some(px) = px { 358 | px 359 | } else { 360 | let all_mids = info_client.all_mids().await?; 361 | all_mids 362 | .get(asset) 363 | .ok_or(Error::AssetNotFound)? 364 | .parse::() 365 | .map_err(|_| Error::FloatStringParse)? 366 | }; 367 | 368 | debug!("px before slippage: {px:?}"); 369 | let slippage_factor = if is_buy { 370 | 1.0 + slippage 371 | } else { 372 | 1.0 - slippage 373 | }; 374 | let px = px * slippage_factor; 375 | 376 | // Round to the correct number of decimal places and significant figures 377 | let px = round_to_significant_and_decimal(px, 5, price_decimals); 378 | 379 | debug!("px after slippage: {px:?}"); 380 | Ok((px, sz_decimals)) 381 | } 382 | 383 | pub async fn order( 384 | &self, 385 | order: ClientOrderRequest, 386 | wallet: Option<&LocalWallet>, 387 | ) -> Result { 388 | self.bulk_order(vec![order], wallet).await 389 | } 390 | 391 | pub async fn order_with_builder( 392 | &self, 393 | order: ClientOrderRequest, 394 | wallet: Option<&LocalWallet>, 395 | builder: BuilderInfo, 396 | ) -> Result { 397 | self.bulk_order_with_builder(vec![order], wallet, builder) 398 | .await 399 | } 400 | 401 | pub async fn bulk_order( 402 | &self, 403 | orders: Vec, 404 | wallet: Option<&LocalWallet>, 405 | ) -> Result { 406 | let wallet = wallet.unwrap_or(&self.wallet); 407 | let timestamp = next_nonce(); 408 | 409 | let mut transformed_orders = Vec::new(); 410 | 411 | for order in orders { 412 | transformed_orders.push(order.convert(&self.coin_to_asset)?); 413 | } 414 | 415 | let action = Actions::Order(BulkOrder { 416 | orders: transformed_orders, 417 | grouping: "na".to_string(), 418 | builder: None, 419 | }); 420 | let connection_id = action.hash(timestamp, self.vault_address)?; 421 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 422 | 423 | let is_mainnet = self.http_client.is_mainnet(); 424 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 425 | self.post(action, signature, timestamp).await 426 | } 427 | 428 | pub async fn bulk_order_with_builder( 429 | &self, 430 | orders: Vec, 431 | wallet: Option<&LocalWallet>, 432 | mut builder: BuilderInfo, 433 | ) -> Result { 434 | let wallet = wallet.unwrap_or(&self.wallet); 435 | let timestamp = next_nonce(); 436 | 437 | builder.builder = builder.builder.to_lowercase(); 438 | 439 | let mut transformed_orders = Vec::new(); 440 | 441 | for order in orders { 442 | transformed_orders.push(order.convert(&self.coin_to_asset)?); 443 | } 444 | 445 | let action = Actions::Order(BulkOrder { 446 | orders: transformed_orders, 447 | grouping: "na".to_string(), 448 | builder: Some(builder), 449 | }); 450 | let connection_id = action.hash(timestamp, self.vault_address)?; 451 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 452 | 453 | let is_mainnet = self.http_client.is_mainnet(); 454 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 455 | self.post(action, signature, timestamp).await 456 | } 457 | 458 | pub async fn cancel( 459 | &self, 460 | cancel: ClientCancelRequest, 461 | wallet: Option<&LocalWallet>, 462 | ) -> Result { 463 | self.bulk_cancel(vec![cancel], wallet).await 464 | } 465 | 466 | pub async fn bulk_cancel( 467 | &self, 468 | cancels: Vec, 469 | wallet: Option<&LocalWallet>, 470 | ) -> Result { 471 | let wallet = wallet.unwrap_or(&self.wallet); 472 | let timestamp = next_nonce(); 473 | 474 | let mut transformed_cancels = Vec::new(); 475 | for cancel in cancels.into_iter() { 476 | let &asset = self 477 | .coin_to_asset 478 | .get(&cancel.asset) 479 | .ok_or(Error::AssetNotFound)?; 480 | transformed_cancels.push(CancelRequest { 481 | asset, 482 | oid: cancel.oid, 483 | }); 484 | } 485 | 486 | let action = Actions::Cancel(BulkCancel { 487 | cancels: transformed_cancels, 488 | }); 489 | let connection_id = action.hash(timestamp, self.vault_address)?; 490 | 491 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 492 | let is_mainnet = self.http_client.is_mainnet(); 493 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 494 | 495 | self.post(action, signature, timestamp).await 496 | } 497 | 498 | pub async fn modify( 499 | &self, 500 | modify: ClientModifyRequest, 501 | wallet: Option<&LocalWallet>, 502 | ) -> Result { 503 | self.bulk_modify(vec![modify], wallet).await 504 | } 505 | 506 | pub async fn bulk_modify( 507 | &self, 508 | modifies: Vec, 509 | wallet: Option<&LocalWallet>, 510 | ) -> Result { 511 | let wallet = wallet.unwrap_or(&self.wallet); 512 | let timestamp = next_nonce(); 513 | 514 | let mut transformed_modifies = Vec::new(); 515 | for modify in modifies.into_iter() { 516 | transformed_modifies.push(ModifyRequest { 517 | oid: modify.oid, 518 | order: modify.order.convert(&self.coin_to_asset)?, 519 | }); 520 | } 521 | 522 | let action = Actions::BatchModify(BulkModify { 523 | modifies: transformed_modifies, 524 | }); 525 | let connection_id = action.hash(timestamp, self.vault_address)?; 526 | 527 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 528 | let is_mainnet = self.http_client.is_mainnet(); 529 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 530 | 531 | self.post(action, signature, timestamp).await 532 | } 533 | 534 | pub async fn cancel_by_cloid( 535 | &self, 536 | cancel: ClientCancelRequestCloid, 537 | wallet: Option<&LocalWallet>, 538 | ) -> Result { 539 | self.bulk_cancel_by_cloid(vec![cancel], wallet).await 540 | } 541 | 542 | pub async fn bulk_cancel_by_cloid( 543 | &self, 544 | cancels: Vec, 545 | wallet: Option<&LocalWallet>, 546 | ) -> Result { 547 | let wallet = wallet.unwrap_or(&self.wallet); 548 | let timestamp = next_nonce(); 549 | 550 | let mut transformed_cancels: Vec = Vec::new(); 551 | for cancel in cancels.into_iter() { 552 | let &asset = self 553 | .coin_to_asset 554 | .get(&cancel.asset) 555 | .ok_or(Error::AssetNotFound)?; 556 | transformed_cancels.push(CancelRequestCloid { 557 | asset, 558 | cloid: uuid_to_hex_string(cancel.cloid), 559 | }); 560 | } 561 | 562 | let action = Actions::CancelByCloid(BulkCancelCloid { 563 | cancels: transformed_cancels, 564 | }); 565 | 566 | let connection_id = action.hash(timestamp, self.vault_address)?; 567 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 568 | let is_mainnet = self.http_client.is_mainnet(); 569 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 570 | 571 | self.post(action, signature, timestamp).await 572 | } 573 | 574 | pub async fn update_leverage( 575 | &self, 576 | leverage: u32, 577 | coin: &str, 578 | is_cross: bool, 579 | wallet: Option<&LocalWallet>, 580 | ) -> Result { 581 | let wallet = wallet.unwrap_or(&self.wallet); 582 | 583 | let timestamp = next_nonce(); 584 | 585 | let &asset_index = self.coin_to_asset.get(coin).ok_or(Error::AssetNotFound)?; 586 | let action = Actions::UpdateLeverage(UpdateLeverage { 587 | asset: asset_index, 588 | is_cross, 589 | leverage, 590 | }); 591 | let connection_id = action.hash(timestamp, self.vault_address)?; 592 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 593 | let is_mainnet = self.http_client.is_mainnet(); 594 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 595 | 596 | self.post(action, signature, timestamp).await 597 | } 598 | 599 | pub async fn update_isolated_margin( 600 | &self, 601 | amount: f64, 602 | coin: &str, 603 | wallet: Option<&LocalWallet>, 604 | ) -> Result { 605 | let wallet = wallet.unwrap_or(&self.wallet); 606 | 607 | let amount = (amount * 1_000_000.0).round() as i64; 608 | let timestamp = next_nonce(); 609 | 610 | let &asset_index = self.coin_to_asset.get(coin).ok_or(Error::AssetNotFound)?; 611 | let action = Actions::UpdateIsolatedMargin(UpdateIsolatedMargin { 612 | asset: asset_index, 613 | is_buy: true, 614 | ntli: amount, 615 | }); 616 | let connection_id = action.hash(timestamp, self.vault_address)?; 617 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 618 | let is_mainnet = self.http_client.is_mainnet(); 619 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 620 | 621 | self.post(action, signature, timestamp).await 622 | } 623 | 624 | pub async fn approve_agent( 625 | &self, 626 | wallet: Option<&LocalWallet>, 627 | ) -> Result<(String, ExchangeResponseStatus)> { 628 | let wallet = wallet.unwrap_or(&self.wallet); 629 | let key = H256::from(generate_random_key()?).encode_hex()[2..].to_string(); 630 | 631 | let address = key 632 | .parse::() 633 | .map_err(|e| Error::PrivateKeyParse(e.to_string()))? 634 | .address(); 635 | 636 | let hyperliquid_chain = if self.http_client.is_mainnet() { 637 | "Mainnet".to_string() 638 | } else { 639 | "Testnet".to_string() 640 | }; 641 | 642 | let nonce = next_nonce(); 643 | let approve_agent = ApproveAgent { 644 | signature_chain_id: 421614.into(), 645 | hyperliquid_chain, 646 | agent_address: address, 647 | agent_name: None, 648 | nonce, 649 | }; 650 | let signature = sign_typed_data(&approve_agent, wallet)?; 651 | let action = serde_json::to_value(Actions::ApproveAgent(approve_agent)) 652 | .map_err(|e| Error::JsonParse(e.to_string()))?; 653 | Ok((key, self.post(action, signature, nonce).await?)) 654 | } 655 | 656 | pub async fn withdraw_from_bridge( 657 | &self, 658 | amount: &str, 659 | destination: &str, 660 | wallet: Option<&LocalWallet>, 661 | ) -> Result { 662 | let wallet = wallet.unwrap_or(&self.wallet); 663 | let hyperliquid_chain = if self.http_client.is_mainnet() { 664 | "Mainnet".to_string() 665 | } else { 666 | "Testnet".to_string() 667 | }; 668 | 669 | let timestamp = next_nonce(); 670 | let withdraw = Withdraw3 { 671 | signature_chain_id: 421614.into(), 672 | hyperliquid_chain, 673 | destination: destination.to_string(), 674 | amount: amount.to_string(), 675 | time: timestamp, 676 | }; 677 | let signature = sign_typed_data(&withdraw, wallet)?; 678 | let action = serde_json::to_value(Actions::Withdraw3(withdraw)) 679 | .map_err(|e| Error::JsonParse(e.to_string()))?; 680 | 681 | self.post(action, signature, timestamp).await 682 | } 683 | 684 | pub async fn spot_transfer( 685 | &self, 686 | amount: &str, 687 | destination: &str, 688 | token: &str, 689 | wallet: Option<&LocalWallet>, 690 | ) -> Result { 691 | let wallet = wallet.unwrap_or(&self.wallet); 692 | let hyperliquid_chain = if self.http_client.is_mainnet() { 693 | "Mainnet".to_string() 694 | } else { 695 | "Testnet".to_string() 696 | }; 697 | 698 | let timestamp = next_nonce(); 699 | let spot_send = SpotSend { 700 | signature_chain_id: 421614.into(), 701 | hyperliquid_chain, 702 | destination: destination.to_string(), 703 | amount: amount.to_string(), 704 | time: timestamp, 705 | token: token.to_string(), 706 | }; 707 | let signature = sign_typed_data(&spot_send, wallet)?; 708 | let action = serde_json::to_value(Actions::SpotSend(spot_send)) 709 | .map_err(|e| Error::JsonParse(e.to_string()))?; 710 | 711 | self.post(action, signature, timestamp).await 712 | } 713 | 714 | pub async fn set_referrer( 715 | &self, 716 | code: String, 717 | wallet: Option<&LocalWallet>, 718 | ) -> Result { 719 | let wallet = wallet.unwrap_or(&self.wallet); 720 | let timestamp = next_nonce(); 721 | 722 | let action = Actions::SetReferrer(SetReferrer { code }); 723 | 724 | let connection_id = action.hash(timestamp, self.vault_address)?; 725 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 726 | 727 | let is_mainnet = self.http_client.is_mainnet(); 728 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 729 | self.post(action, signature, timestamp).await 730 | } 731 | 732 | pub async fn approve_builder_fee( 733 | &self, 734 | builder: String, 735 | max_fee_rate: String, 736 | wallet: Option<&LocalWallet>, 737 | ) -> Result { 738 | let wallet = wallet.unwrap_or(&self.wallet); 739 | let timestamp = next_nonce(); 740 | 741 | let hyperliquid_chain = if self.http_client.is_mainnet() { 742 | "Mainnet".to_string() 743 | } else { 744 | "Testnet".to_string() 745 | }; 746 | 747 | let action = Actions::ApproveBuilderFee(ApproveBuilderFee { 748 | signature_chain_id: 421614.into(), 749 | hyperliquid_chain, 750 | builder, 751 | max_fee_rate, 752 | nonce: timestamp, 753 | }); 754 | 755 | let connection_id = action.hash(timestamp, self.vault_address)?; 756 | let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; 757 | 758 | let is_mainnet = self.http_client.is_mainnet(); 759 | let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; 760 | self.post(action, signature, timestamp).await 761 | } 762 | } 763 | 764 | fn round_to_decimals(value: f64, decimals: u32) -> f64 { 765 | let factor = 10f64.powi(decimals as i32); 766 | (value * factor).round() / factor 767 | } 768 | 769 | fn round_to_significant_and_decimal(value: f64, sig_figs: u32, max_decimals: u32) -> f64 { 770 | let abs_value = value.abs(); 771 | let magnitude = abs_value.log10().floor() as i32; 772 | let scale = 10f64.powi(sig_figs as i32 - magnitude - 1); 773 | let rounded = (abs_value * scale).round() / scale; 774 | round_to_decimals(rounded.copysign(value), max_decimals) 775 | } 776 | 777 | #[cfg(test)] 778 | mod tests { 779 | use std::str::FromStr; 780 | 781 | use super::*; 782 | use crate::{ 783 | exchange::order::{Limit, OrderRequest, Trigger}, 784 | Order, 785 | }; 786 | 787 | fn get_wallet() -> Result { 788 | let priv_key = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e"; 789 | priv_key 790 | .parse::() 791 | .map_err(|e| Error::Wallet(e.to_string())) 792 | } 793 | 794 | #[test] 795 | fn test_limit_order_action_hashing() -> Result<()> { 796 | let wallet = get_wallet()?; 797 | let action = Actions::Order(BulkOrder { 798 | orders: vec![OrderRequest { 799 | asset: 1, 800 | is_buy: true, 801 | limit_px: "2000.0".to_string(), 802 | sz: "3.5".to_string(), 803 | reduce_only: false, 804 | order_type: Order::Limit(Limit { 805 | tif: "Ioc".to_string(), 806 | }), 807 | cloid: None, 808 | }], 809 | grouping: "na".to_string(), 810 | builder: None, 811 | }); 812 | let connection_id = action.hash(1583838, None)?; 813 | 814 | let signature = sign_l1_action(&wallet, connection_id, true)?; 815 | assert_eq!(signature.to_string(), "77957e58e70f43b6b68581f2dc42011fc384538a2e5b7bf42d5b936f19fbb67360721a8598727230f67080efee48c812a6a4442013fd3b0eed509171bef9f23f1c"); 816 | 817 | let signature = sign_l1_action(&wallet, connection_id, false)?; 818 | assert_eq!(signature.to_string(), "cd0925372ff1ed499e54883e9a6205ecfadec748f80ec463fe2f84f1209648776377961965cb7b12414186b1ea291e95fd512722427efcbcfb3b0b2bcd4d79d01c"); 819 | 820 | Ok(()) 821 | } 822 | 823 | #[test] 824 | fn test_limit_order_action_hashing_with_cloid() -> Result<()> { 825 | let cloid = uuid::Uuid::from_str("1e60610f-0b3d-4205-97c8-8c1fed2ad5ee") 826 | .map_err(|_e| uuid::Uuid::new_v4()); 827 | let wallet = get_wallet()?; 828 | let action = Actions::Order(BulkOrder { 829 | orders: vec![OrderRequest { 830 | asset: 1, 831 | is_buy: true, 832 | limit_px: "2000.0".to_string(), 833 | sz: "3.5".to_string(), 834 | reduce_only: false, 835 | order_type: Order::Limit(Limit { 836 | tif: "Ioc".to_string(), 837 | }), 838 | cloid: Some(uuid_to_hex_string(cloid.unwrap())), 839 | }], 840 | grouping: "na".to_string(), 841 | builder: None, 842 | }); 843 | let connection_id = action.hash(1583838, None)?; 844 | 845 | let signature = sign_l1_action(&wallet, connection_id, true)?; 846 | assert_eq!(signature.to_string(), "d3e894092eb27098077145714630a77bbe3836120ee29df7d935d8510b03a08f456de5ec1be82aa65fc6ecda9ef928b0445e212517a98858cfaa251c4cd7552b1c"); 847 | 848 | let signature = sign_l1_action(&wallet, connection_id, false)?; 849 | assert_eq!(signature.to_string(), "3768349dbb22a7fd770fc9fc50c7b5124a7da342ea579b309f58002ceae49b4357badc7909770919c45d850aabb08474ff2b7b3204ae5b66d9f7375582981f111c"); 850 | 851 | Ok(()) 852 | } 853 | 854 | #[test] 855 | fn test_tpsl_order_action_hashing() -> Result<()> { 856 | for (tpsl, mainnet_signature, testnet_signature) in [ 857 | ( 858 | "tp", 859 | "b91e5011dff15e4b4a40753730bda44972132e7b75641f3cac58b66159534a170d422ee1ac3c7a7a2e11e298108a2d6b8da8612caceaeeb3e571de3b2dfda9e41b", 860 | "6df38b609904d0d4439884756b8f366f22b3a081801dbdd23f279094a2299fac6424cb0cdc48c3706aeaa368f81959e91059205403d3afd23a55983f710aee871b" 861 | ), 862 | ( 863 | "sl", 864 | "8456d2ace666fce1bee1084b00e9620fb20e810368841e9d4dd80eb29014611a0843416e51b1529c22dd2fc28f7ff8f6443875635c72011f60b62cbb8ce90e2d1c", 865 | "eb5bdb52297c1d19da45458758bd569dcb24c07e5c7bd52cf76600fd92fdd8213e661e21899c985421ec018a9ee7f3790e7b7d723a9932b7b5adcd7def5354601c" 866 | ) 867 | ] { 868 | let wallet = get_wallet()?; 869 | let action = Actions::Order(BulkOrder { 870 | orders: vec![ 871 | OrderRequest { 872 | asset: 1, 873 | is_buy: true, 874 | limit_px: "2000.0".to_string(), 875 | sz: "3.5".to_string(), 876 | reduce_only: false, 877 | order_type: Order::Trigger(Trigger { 878 | trigger_px: "2000.0".to_string(), 879 | is_market: true, 880 | tpsl: tpsl.to_string(), 881 | }), 882 | cloid: None, 883 | } 884 | ], 885 | grouping: "na".to_string(), 886 | builder: None, 887 | }); 888 | let connection_id = action.hash(1583838, None)?; 889 | 890 | let signature = sign_l1_action(&wallet, connection_id, true)?; 891 | assert_eq!(signature.to_string(), mainnet_signature); 892 | 893 | let signature = sign_l1_action(&wallet, connection_id, false)?; 894 | assert_eq!(signature.to_string(), testnet_signature); 895 | } 896 | Ok(()) 897 | } 898 | 899 | #[test] 900 | fn test_cancel_action_hashing() -> Result<()> { 901 | let wallet = get_wallet()?; 902 | let action = Actions::Cancel(BulkCancel { 903 | cancels: vec![CancelRequest { 904 | asset: 1, 905 | oid: 82382, 906 | }], 907 | }); 908 | let connection_id = action.hash(1583838, None)?; 909 | 910 | let signature = sign_l1_action(&wallet, connection_id, true)?; 911 | assert_eq!(signature.to_string(), "02f76cc5b16e0810152fa0e14e7b219f49c361e3325f771544c6f54e157bf9fa17ed0afc11a98596be85d5cd9f86600aad515337318f7ab346e5ccc1b03425d51b"); 912 | 913 | let signature = sign_l1_action(&wallet, connection_id, false)?; 914 | assert_eq!(signature.to_string(), "6ffebadfd48067663390962539fbde76cfa36f53be65abe2ab72c9db6d0db44457720db9d7c4860f142a484f070c84eb4b9694c3a617c83f0d698a27e55fd5e01c"); 915 | 916 | Ok(()) 917 | } 918 | } 919 | -------------------------------------------------------------------------------- /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/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/exchange/modify.rs: -------------------------------------------------------------------------------- 1 | use super::{order::OrderRequest, ClientOrderRequest}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug)] 5 | pub struct ClientModifyRequest { 6 | pub oid: u64, 7 | pub order: ClientOrderRequest, 8 | } 9 | 10 | #[derive(Serialize, Deserialize, Debug, Clone)] 11 | pub struct ModifyRequest { 12 | pub oid: u64, 13 | pub order: OrderRequest, 14 | } 15 | -------------------------------------------------------------------------------- /src/exchange/order.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | errors::Error, 3 | helpers::{float_to_string_for_hashing, uuid_to_hex_string}, 4 | prelude::*, 5 | }; 6 | use ethers::signers::LocalWallet; 7 | use serde::{Deserialize, Serialize}; 8 | use std::collections::HashMap; 9 | use uuid::Uuid; 10 | 11 | #[derive(Deserialize, Serialize, Clone, Debug)] 12 | pub struct Limit { 13 | pub tif: String, 14 | } 15 | 16 | #[derive(Deserialize, Serialize, Debug, Clone)] 17 | #[serde(rename_all = "camelCase")] 18 | pub struct Trigger { 19 | pub is_market: bool, 20 | pub trigger_px: String, 21 | pub tpsl: String, 22 | } 23 | 24 | #[derive(Deserialize, Serialize, Debug, Clone)] 25 | #[serde(rename_all = "camelCase")] 26 | pub enum Order { 27 | Limit(Limit), 28 | Trigger(Trigger), 29 | } 30 | 31 | #[derive(Deserialize, Serialize, Debug, Clone)] 32 | #[serde(rename_all = "camelCase")] 33 | pub struct OrderRequest { 34 | #[serde(rename = "a", alias = "asset")] 35 | pub asset: u32, 36 | #[serde(rename = "b", alias = "isBuy")] 37 | pub is_buy: bool, 38 | #[serde(rename = "p", alias = "limitPx")] 39 | pub limit_px: String, 40 | #[serde(rename = "s", alias = "sz")] 41 | pub sz: String, 42 | #[serde(rename = "r", alias = "reduceOnly", default)] 43 | pub reduce_only: bool, 44 | #[serde(rename = "t", alias = "orderType")] 45 | pub order_type: Order, 46 | #[serde(rename = "c", alias = "cloid", skip_serializing_if = "Option::is_none")] 47 | pub cloid: Option, 48 | } 49 | 50 | #[derive(Debug)] 51 | pub struct ClientLimit { 52 | pub tif: String, 53 | } 54 | 55 | #[derive(Debug)] 56 | pub struct ClientTrigger { 57 | pub is_market: bool, 58 | pub trigger_px: f64, 59 | pub tpsl: String, 60 | } 61 | 62 | #[derive(Debug)] 63 | pub struct MarketOrderParams<'a> { 64 | pub asset: &'a str, 65 | pub is_buy: bool, 66 | pub sz: f64, 67 | pub px: Option, 68 | pub slippage: Option, 69 | pub cloid: Option, 70 | pub wallet: Option<&'a LocalWallet>, 71 | } 72 | 73 | #[derive(Debug)] 74 | pub struct MarketCloseParams<'a> { 75 | pub asset: &'a str, 76 | pub sz: Option, 77 | pub px: Option, 78 | pub slippage: Option, 79 | pub cloid: Option, 80 | pub wallet: Option<&'a LocalWallet>, 81 | } 82 | 83 | #[derive(Debug)] 84 | pub enum ClientOrder { 85 | Limit(ClientLimit), 86 | Trigger(ClientTrigger), 87 | } 88 | 89 | #[derive(Debug)] 90 | pub struct ClientOrderRequest { 91 | pub asset: String, 92 | pub is_buy: bool, 93 | pub reduce_only: bool, 94 | pub limit_px: f64, 95 | pub sz: f64, 96 | pub cloid: Option, 97 | pub order_type: ClientOrder, 98 | } 99 | 100 | impl ClientOrderRequest { 101 | pub(crate) fn convert(self, coin_to_asset: &HashMap) -> Result { 102 | let order_type = match self.order_type { 103 | ClientOrder::Limit(limit) => Order::Limit(Limit { tif: limit.tif }), 104 | ClientOrder::Trigger(trigger) => Order::Trigger(Trigger { 105 | trigger_px: float_to_string_for_hashing(trigger.trigger_px), 106 | is_market: trigger.is_market, 107 | tpsl: trigger.tpsl, 108 | }), 109 | }; 110 | let &asset = coin_to_asset.get(&self.asset).ok_or(Error::AssetNotFound)?; 111 | 112 | let cloid = self.cloid.map(uuid_to_hex_string); 113 | 114 | Ok(OrderRequest { 115 | asset, 116 | is_buy: self.is_buy, 117 | reduce_only: self.reduce_only, 118 | limit_px: float_to_string_for_hashing(self.limit_px), 119 | sz: float_to_string_for_hashing(self.sz), 120 | order_type, 121 | cloid, 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/helpers.rs: -------------------------------------------------------------------------------- 1 | use crate::{consts::*, prelude::*, Error}; 2 | use chrono::prelude::Utc; 3 | use lazy_static::lazy_static; 4 | use log::info; 5 | use rand::{thread_rng, Rng}; 6 | use std::sync::atomic::{AtomicU64, Ordering}; 7 | use uuid::Uuid; 8 | 9 | fn now_timestamp_ms() -> u64 { 10 | let now = Utc::now(); 11 | now.timestamp_millis() as u64 12 | } 13 | 14 | pub(crate) fn next_nonce() -> u64 { 15 | let nonce = CUR_NONCE.fetch_add(1, Ordering::Relaxed); 16 | let now_ms = now_timestamp_ms(); 17 | if nonce > now_ms + 1000 { 18 | info!("nonce progressed too far ahead {nonce} {now_ms}"); 19 | } 20 | // more than 300 seconds behind 21 | if nonce + 300000 < now_ms { 22 | CUR_NONCE.fetch_max(now_ms, Ordering::Relaxed); 23 | } 24 | nonce 25 | } 26 | 27 | pub(crate) const WIRE_DECIMALS: u8 = 8; 28 | 29 | pub(crate) fn float_to_string_for_hashing(x: f64) -> String { 30 | let mut x = format!("{:.*}", WIRE_DECIMALS.into(), x); 31 | while x.ends_with('0') { 32 | x.pop(); 33 | } 34 | if x.ends_with('.') { 35 | x.pop(); 36 | } 37 | if x == "-0" { 38 | "0".to_string() 39 | } else { 40 | x 41 | } 42 | } 43 | 44 | pub(crate) fn uuid_to_hex_string(uuid: Uuid) -> String { 45 | let hex_string = uuid 46 | .as_bytes() 47 | .iter() 48 | .map(|byte| format!("{:02x}", byte)) 49 | .collect::>() 50 | .join(""); 51 | format!("0x{}", hex_string) 52 | } 53 | 54 | pub(crate) fn generate_random_key() -> Result<[u8; 32]> { 55 | let mut arr = [0u8; 32]; 56 | thread_rng() 57 | .try_fill(&mut arr[..]) 58 | .map_err(|e| Error::RandGen(e.to_string()))?; 59 | Ok(arr) 60 | } 61 | 62 | pub fn truncate_float(float: f64, decimals: u32, round_up: bool) -> f64 { 63 | let pow10 = 10i64.pow(decimals) as f64; 64 | let mut float = (float * pow10) as u64; 65 | if round_up { 66 | float += 1; 67 | } 68 | float as f64 / pow10 69 | } 70 | 71 | pub fn bps_diff(x: f64, y: f64) -> u16 { 72 | if x.abs() < EPSILON { 73 | INF_BPS 74 | } else { 75 | (((y - x).abs() / (x)) * 10_000.0) as u16 76 | } 77 | } 78 | 79 | #[derive(Copy, Clone)] 80 | pub enum BaseUrl { 81 | Localhost, 82 | Testnet, 83 | Mainnet, 84 | } 85 | 86 | impl BaseUrl { 87 | pub(crate) fn get_url(&self) -> String { 88 | match self { 89 | BaseUrl::Localhost => LOCAL_API_URL.to_string(), 90 | BaseUrl::Mainnet => MAINNET_API_URL.to_string(), 91 | BaseUrl::Testnet => TESTNET_API_URL.to_string(), 92 | } 93 | } 94 | } 95 | 96 | lazy_static! { 97 | static ref CUR_NONCE: AtomicU64 = AtomicU64::new(now_timestamp_ms()); 98 | } 99 | 100 | #[cfg(test)] 101 | mod tests { 102 | use super::*; 103 | 104 | #[test] 105 | fn float_to_string_for_hashing_test() { 106 | assert_eq!(float_to_string_for_hashing(0.), "0".to_string()); 107 | assert_eq!(float_to_string_for_hashing(-0.), "0".to_string()); 108 | assert_eq!(float_to_string_for_hashing(-0.0000), "0".to_string()); 109 | assert_eq!( 110 | float_to_string_for_hashing(0.00076000), 111 | "0.00076".to_string() 112 | ); 113 | assert_eq!( 114 | float_to_string_for_hashing(0.00000001), 115 | "0.00000001".to_string() 116 | ); 117 | assert_eq!( 118 | float_to_string_for_hashing(0.12345678), 119 | "0.12345678".to_string() 120 | ); 121 | assert_eq!( 122 | float_to_string_for_hashing(87654321.12345678), 123 | "87654321.12345678".to_string() 124 | ); 125 | assert_eq!( 126 | float_to_string_for_hashing(987654321.00000000), 127 | "987654321".to_string() 128 | ); 129 | assert_eq!( 130 | float_to_string_for_hashing(87654321.1234), 131 | "87654321.1234".to_string() 132 | ); 133 | assert_eq!(float_to_string_for_hashing(0.000760), "0.00076".to_string()); 134 | assert_eq!(float_to_string_for_hashing(0.00076), "0.00076".to_string()); 135 | assert_eq!( 136 | float_to_string_for_hashing(987654321.0), 137 | "987654321".to_string() 138 | ); 139 | assert_eq!( 140 | float_to_string_for_hashing(987654321.), 141 | "987654321".to_string() 142 | ); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/info/info_client.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | info::{ 3 | CandlesSnapshotResponse, FundingHistoryResponse, L2SnapshotResponse, OpenOrdersResponse, 4 | OrderInfo, RecentTradesResponse, UserFillsResponse, UserStateResponse, 5 | }, 6 | meta::{Meta, SpotMeta, SpotMetaAndAssetCtxs}, 7 | prelude::*, 8 | req::HttpClient, 9 | ws::{Subscription, WsManager}, 10 | BaseUrl, Error, Message, OrderStatusResponse, ReferralResponse, UserFeesResponse, 11 | UserFundingResponse, UserTokenBalanceResponse, 12 | }; 13 | 14 | use ethers::types::H160; 15 | use reqwest::Client; 16 | use serde::{Deserialize, Serialize}; 17 | use std::collections::HashMap; 18 | use tokio::sync::mpsc::UnboundedSender; 19 | 20 | #[derive(Deserialize, Serialize, Debug, Clone)] 21 | #[serde(rename_all = "camelCase")] 22 | pub struct CandleSnapshotRequest { 23 | coin: String, 24 | interval: String, 25 | start_time: u64, 26 | end_time: u64, 27 | } 28 | 29 | #[derive(Deserialize, Serialize, Debug, Clone)] 30 | #[serde(tag = "type")] 31 | #[serde(rename_all = "camelCase")] 32 | pub enum InfoRequest { 33 | #[serde(rename = "clearinghouseState")] 34 | UserState { 35 | user: H160, 36 | }, 37 | #[serde(rename = "batchClearinghouseStates")] 38 | UserStates { 39 | users: Vec, 40 | }, 41 | #[serde(rename = "spotClearinghouseState")] 42 | UserTokenBalances { 43 | user: H160, 44 | }, 45 | UserFees { 46 | user: H160, 47 | }, 48 | OpenOrders { 49 | user: H160, 50 | }, 51 | OrderStatus { 52 | user: H160, 53 | oid: u64, 54 | }, 55 | Meta, 56 | SpotMeta, 57 | SpotMetaAndAssetCtxs, 58 | AllMids, 59 | UserFills { 60 | user: H160, 61 | }, 62 | #[serde(rename_all = "camelCase")] 63 | FundingHistory { 64 | coin: String, 65 | start_time: u64, 66 | end_time: Option, 67 | }, 68 | #[serde(rename_all = "camelCase")] 69 | UserFunding { 70 | user: H160, 71 | start_time: u64, 72 | end_time: Option, 73 | }, 74 | L2Book { 75 | coin: String, 76 | }, 77 | RecentTrades { 78 | coin: String, 79 | }, 80 | #[serde(rename_all = "camelCase")] 81 | CandleSnapshot { 82 | req: CandleSnapshotRequest, 83 | }, 84 | Referral { 85 | user: H160, 86 | }, 87 | HistoricalOrders { 88 | user: H160, 89 | }, 90 | } 91 | 92 | #[derive(Debug)] 93 | pub struct InfoClient { 94 | pub http_client: HttpClient, 95 | pub(crate) ws_manager: Option, 96 | reconnect: bool, 97 | } 98 | 99 | impl InfoClient { 100 | pub async fn new(client: Option, base_url: Option) -> Result { 101 | Self::new_internal(client, base_url, false).await 102 | } 103 | 104 | pub async fn with_reconnect( 105 | client: Option, 106 | base_url: Option, 107 | ) -> Result { 108 | Self::new_internal(client, base_url, true).await 109 | } 110 | 111 | async fn new_internal( 112 | client: Option, 113 | base_url: Option, 114 | reconnect: bool, 115 | ) -> Result { 116 | let client = client.unwrap_or_default(); 117 | let base_url = base_url.unwrap_or(BaseUrl::Mainnet).get_url(); 118 | 119 | Ok(InfoClient { 120 | http_client: HttpClient { client, base_url }, 121 | ws_manager: None, 122 | reconnect, 123 | }) 124 | } 125 | 126 | pub async fn subscribe( 127 | &mut self, 128 | subscription: Subscription, 129 | sender_channel: UnboundedSender, 130 | ) -> Result { 131 | if self.ws_manager.is_none() { 132 | let ws_manager = WsManager::new( 133 | format!("ws{}/ws", &self.http_client.base_url[4..]), 134 | self.reconnect, 135 | ) 136 | .await?; 137 | self.ws_manager = Some(ws_manager); 138 | } 139 | 140 | let identifier = 141 | serde_json::to_string(&subscription).map_err(|e| Error::JsonParse(e.to_string()))?; 142 | 143 | self.ws_manager 144 | .as_mut() 145 | .ok_or(Error::WsManagerNotFound)? 146 | .add_subscription(identifier, sender_channel) 147 | .await 148 | } 149 | 150 | pub async fn unsubscribe(&mut self, subscription_id: u32) -> Result<()> { 151 | if self.ws_manager.is_none() { 152 | let ws_manager = WsManager::new( 153 | format!("ws{}/ws", &self.http_client.base_url[4..]), 154 | self.reconnect, 155 | ) 156 | .await?; 157 | self.ws_manager = Some(ws_manager); 158 | } 159 | 160 | self.ws_manager 161 | .as_mut() 162 | .ok_or(Error::WsManagerNotFound)? 163 | .remove_subscription(subscription_id) 164 | .await 165 | } 166 | 167 | async fn send_info_request Deserialize<'a>>( 168 | &self, 169 | info_request: InfoRequest, 170 | ) -> Result { 171 | let data = 172 | serde_json::to_string(&info_request).map_err(|e| Error::JsonParse(e.to_string()))?; 173 | 174 | let return_data = self.http_client.post("/info", data).await?; 175 | serde_json::from_str(&return_data).map_err(|e| Error::JsonParse(e.to_string())) 176 | } 177 | 178 | pub async fn open_orders(&self, address: H160) -> Result> { 179 | let input = InfoRequest::OpenOrders { user: address }; 180 | self.send_info_request(input).await 181 | } 182 | 183 | pub async fn user_state(&self, address: H160) -> Result { 184 | let input = InfoRequest::UserState { user: address }; 185 | self.send_info_request(input).await 186 | } 187 | 188 | pub async fn user_states(&self, addresses: Vec) -> Result> { 189 | let input = InfoRequest::UserStates { users: addresses }; 190 | self.send_info_request(input).await 191 | } 192 | 193 | pub async fn user_token_balances(&self, address: H160) -> Result { 194 | let input = InfoRequest::UserTokenBalances { user: address }; 195 | self.send_info_request(input).await 196 | } 197 | 198 | pub async fn user_fees(&self, address: H160) -> Result { 199 | let input = InfoRequest::UserFees { user: address }; 200 | self.send_info_request(input).await 201 | } 202 | 203 | pub async fn meta(&self) -> Result { 204 | let input = InfoRequest::Meta; 205 | self.send_info_request(input).await 206 | } 207 | 208 | pub async fn spot_meta(&self) -> Result { 209 | let input = InfoRequest::SpotMeta; 210 | self.send_info_request(input).await 211 | } 212 | 213 | pub async fn spot_meta_and_asset_contexts(&self) -> Result> { 214 | let input = InfoRequest::SpotMetaAndAssetCtxs; 215 | self.send_info_request(input).await 216 | } 217 | 218 | pub async fn all_mids(&self) -> Result> { 219 | let input = InfoRequest::AllMids; 220 | self.send_info_request(input).await 221 | } 222 | 223 | pub async fn user_fills(&self, address: H160) -> Result> { 224 | let input = InfoRequest::UserFills { user: address }; 225 | self.send_info_request(input).await 226 | } 227 | 228 | pub async fn funding_history( 229 | &self, 230 | coin: String, 231 | start_time: u64, 232 | end_time: Option, 233 | ) -> Result> { 234 | let input = InfoRequest::FundingHistory { 235 | coin, 236 | start_time, 237 | end_time, 238 | }; 239 | self.send_info_request(input).await 240 | } 241 | 242 | pub async fn user_funding_history( 243 | &self, 244 | user: H160, 245 | start_time: u64, 246 | end_time: Option, 247 | ) -> Result> { 248 | let input = InfoRequest::UserFunding { 249 | user, 250 | start_time, 251 | end_time, 252 | }; 253 | self.send_info_request(input).await 254 | } 255 | 256 | pub async fn recent_trades(&self, coin: String) -> Result> { 257 | let input = InfoRequest::RecentTrades { coin }; 258 | self.send_info_request(input).await 259 | } 260 | 261 | pub async fn l2_snapshot(&self, coin: String) -> Result { 262 | let input = InfoRequest::L2Book { coin }; 263 | self.send_info_request(input).await 264 | } 265 | 266 | pub async fn candles_snapshot( 267 | &self, 268 | coin: String, 269 | interval: String, 270 | start_time: u64, 271 | end_time: u64, 272 | ) -> Result> { 273 | let input = InfoRequest::CandleSnapshot { 274 | req: CandleSnapshotRequest { 275 | coin, 276 | interval, 277 | start_time, 278 | end_time, 279 | }, 280 | }; 281 | self.send_info_request(input).await 282 | } 283 | 284 | pub async fn query_order_by_oid(&self, address: H160, oid: u64) -> Result { 285 | let input = InfoRequest::OrderStatus { user: address, oid }; 286 | self.send_info_request(input).await 287 | } 288 | 289 | pub async fn query_referral_state(&self, address: H160) -> Result { 290 | let input = InfoRequest::Referral { user: address }; 291 | self.send_info_request(input).await 292 | } 293 | 294 | pub async fn historical_orders(&self, address: H160) -> Result> { 295 | let input = InfoRequest::HistoricalOrders { user: address }; 296 | self.send_info_request(input).await 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /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/info/response_structs.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | info::{AssetPosition, Level, MarginSummary}, 3 | DailyUserVlm, Delta, FeeSchedule, OrderInfo, Referrer, ReferrerState, UserTokenBalance, 4 | }; 5 | use serde::Deserialize; 6 | 7 | #[derive(Deserialize, Debug)] 8 | #[serde(rename_all = "camelCase")] 9 | pub struct UserStateResponse { 10 | pub asset_positions: Vec, 11 | pub cross_margin_summary: MarginSummary, 12 | pub margin_summary: MarginSummary, 13 | pub withdrawable: String, 14 | } 15 | 16 | #[derive(Deserialize, Debug)] 17 | pub struct UserTokenBalanceResponse { 18 | pub balances: Vec, 19 | } 20 | 21 | #[derive(Deserialize, Debug)] 22 | #[serde(rename_all = "camelCase")] 23 | pub struct UserFeesResponse { 24 | pub active_referral_discount: String, 25 | pub daily_user_vlm: Vec, 26 | pub fee_schedule: FeeSchedule, 27 | pub user_add_rate: String, 28 | pub user_cross_rate: String, 29 | } 30 | 31 | #[derive(serde::Deserialize, Debug)] 32 | #[serde(rename_all = "camelCase")] 33 | pub struct OpenOrdersResponse { 34 | pub coin: String, 35 | pub limit_px: String, 36 | pub oid: u64, 37 | pub side: String, 38 | pub sz: String, 39 | pub timestamp: u64, 40 | } 41 | 42 | #[derive(serde::Deserialize, Debug)] 43 | #[serde(rename_all = "camelCase")] 44 | pub struct UserFillsResponse { 45 | pub closed_pnl: String, 46 | pub coin: String, 47 | pub crossed: bool, 48 | pub dir: String, 49 | pub hash: String, 50 | pub oid: u64, 51 | pub px: String, 52 | pub side: String, 53 | pub start_position: String, 54 | pub sz: String, 55 | pub time: u64, 56 | pub fee: String, 57 | } 58 | 59 | #[derive(serde::Deserialize, Debug)] 60 | #[serde(rename_all = "camelCase")] 61 | pub struct FundingHistoryResponse { 62 | pub coin: String, 63 | pub funding_rate: String, 64 | pub premium: String, 65 | pub time: u64, 66 | } 67 | 68 | #[derive(Deserialize, Debug)] 69 | pub struct UserFundingResponse { 70 | pub time: u64, 71 | pub hash: String, 72 | pub delta: Delta, 73 | } 74 | 75 | #[derive(serde::Deserialize, Debug)] 76 | #[serde(rename_all = "camelCase")] 77 | pub struct L2SnapshotResponse { 78 | pub coin: String, 79 | pub levels: Vec>, 80 | pub time: u64, 81 | } 82 | 83 | #[derive(serde::Deserialize, Debug)] 84 | #[serde(rename_all = "camelCase")] 85 | pub struct RecentTradesResponse { 86 | pub coin: String, 87 | pub side: String, 88 | pub px: String, 89 | pub sz: String, 90 | pub time: u64, 91 | pub hash: String, 92 | } 93 | 94 | #[derive(serde::Deserialize, Debug)] 95 | pub struct CandlesSnapshotResponse { 96 | #[serde(rename = "t")] 97 | pub time_open: u64, 98 | #[serde(rename = "T")] 99 | pub time_close: u64, 100 | #[serde(rename = "s")] 101 | pub coin: String, 102 | #[serde(rename = "i")] 103 | pub candle_interval: String, 104 | #[serde(rename = "o")] 105 | pub open: String, 106 | #[serde(rename = "c")] 107 | pub close: String, 108 | #[serde(rename = "h")] 109 | pub high: String, 110 | #[serde(rename = "l")] 111 | pub low: String, 112 | #[serde(rename = "v")] 113 | pub vlm: String, 114 | #[serde(rename = "n")] 115 | pub num_trades: u64, 116 | } 117 | 118 | #[derive(Deserialize, Debug)] 119 | pub struct OrderStatusResponse { 120 | pub status: String, 121 | /// `None` if the order is not found 122 | #[serde(default)] 123 | pub order: Option, 124 | } 125 | 126 | #[derive(Deserialize, Debug)] 127 | #[serde(rename_all = "camelCase")] 128 | pub struct ReferralResponse { 129 | pub referred_by: Option, 130 | pub cum_vlm: String, 131 | pub unclaimed_rewards: String, 132 | pub claimed_rewards: String, 133 | pub referrer_state: ReferrerState, 134 | } 135 | -------------------------------------------------------------------------------- /src/info/sub_structs.rs: -------------------------------------------------------------------------------- 1 | use ethers::types::H160; 2 | use serde::Deserialize; 3 | 4 | #[derive(Deserialize, Debug)] 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: String, 145 | pub cloid: Option, 146 | } 147 | 148 | #[derive(Deserialize, Debug)] 149 | #[serde(rename_all = "camelCase")] 150 | pub struct Referrer { 151 | pub referrer: H160, 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/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(unreachable_pub)] 2 | mod consts; 3 | mod errors; 4 | mod exchange; 5 | mod helpers; 6 | mod info; 7 | mod market_maker; 8 | mod meta; 9 | mod prelude; 10 | mod proxy_digest; 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 errors::Error; 16 | pub use exchange::*; 17 | pub use helpers::{bps_diff, truncate_float, BaseUrl}; 18 | pub use info::{info_client::*, *}; 19 | pub use market_maker::{MarketMaker, MarketMakerInput, MarketMakerRestingOrder}; 20 | pub use meta::{AssetMeta, Meta}; 21 | pub use ws::*; 22 | -------------------------------------------------------------------------------- /src/market_maker.rs: -------------------------------------------------------------------------------- 1 | use ethers::{ 2 | signers::{LocalWallet, Signer}, 3 | types::H160, 4 | }; 5 | use log::{error, info}; 6 | 7 | use tokio::sync::mpsc::unbounded_channel; 8 | 9 | use crate::{ 10 | bps_diff, truncate_float, BaseUrl, ClientCancelRequest, ClientLimit, ClientOrder, 11 | ClientOrderRequest, ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, InfoClient, 12 | Message, Subscription, UserData, EPSILON, 13 | }; 14 | #[derive(Debug)] 15 | pub struct MarketMakerRestingOrder { 16 | pub oid: u64, 17 | pub position: f64, 18 | pub price: f64, 19 | } 20 | 21 | #[derive(Debug)] 22 | pub struct MarketMakerInput { 23 | pub asset: String, 24 | pub target_liquidity: f64, // Amount of liquidity on both sides to target 25 | pub half_spread: u16, // Half of the spread for our market making (in BPS) 26 | pub max_bps_diff: u16, // Max deviation before we cancel and put new orders on the book (in BPS) 27 | pub max_absolute_position_size: f64, // Absolute value of the max position we can take on 28 | pub decimals: u32, // Decimals to round to for pricing 29 | pub wallet: LocalWallet, // Wallet containing private key 30 | } 31 | 32 | #[derive(Debug)] 33 | pub struct MarketMaker { 34 | pub asset: String, 35 | pub target_liquidity: f64, 36 | pub half_spread: u16, 37 | pub max_bps_diff: u16, 38 | pub max_absolute_position_size: f64, 39 | pub decimals: u32, 40 | pub lower_resting: MarketMakerRestingOrder, 41 | pub upper_resting: MarketMakerRestingOrder, 42 | pub cur_position: f64, 43 | pub latest_mid_price: f64, 44 | pub info_client: InfoClient, 45 | pub exchange_client: ExchangeClient, 46 | pub user_address: H160, 47 | } 48 | 49 | impl MarketMaker { 50 | pub async fn new(input: MarketMakerInput) -> MarketMaker { 51 | let user_address = input.wallet.address(); 52 | 53 | let info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); 54 | let exchange_client = 55 | ExchangeClient::new(None, input.wallet, Some(BaseUrl::Testnet), None, None) 56 | .await 57 | .unwrap(); 58 | 59 | MarketMaker { 60 | asset: input.asset, 61 | target_liquidity: input.target_liquidity, 62 | half_spread: input.half_spread, 63 | max_bps_diff: input.max_bps_diff, 64 | max_absolute_position_size: input.max_absolute_position_size, 65 | decimals: input.decimals, 66 | lower_resting: MarketMakerRestingOrder { 67 | oid: 0, 68 | position: 0.0, 69 | price: -1.0, 70 | }, 71 | upper_resting: MarketMakerRestingOrder { 72 | oid: 0, 73 | position: 0.0, 74 | price: -1.0, 75 | }, 76 | cur_position: 0.0, 77 | latest_mid_price: -1.0, 78 | info_client, 79 | exchange_client, 80 | user_address, 81 | } 82 | } 83 | 84 | pub async fn start(&mut self) { 85 | let (sender, mut receiver) = unbounded_channel(); 86 | 87 | // Subscribe to UserEvents for fills 88 | self.info_client 89 | .subscribe( 90 | Subscription::UserEvents { 91 | user: self.user_address, 92 | }, 93 | sender.clone(), 94 | ) 95 | .await 96 | .unwrap(); 97 | 98 | // Subscribe to AllMids so we can market make around the mid price 99 | self.info_client 100 | .subscribe(Subscription::AllMids, sender) 101 | .await 102 | .unwrap(); 103 | 104 | loop { 105 | let message = receiver.recv().await.unwrap(); 106 | match message { 107 | Message::AllMids(all_mids) => { 108 | let all_mids = all_mids.data.mids; 109 | let mid = all_mids.get(&self.asset); 110 | if let Some(mid) = mid { 111 | let mid: f64 = mid.parse().unwrap(); 112 | self.latest_mid_price = mid; 113 | // Check to see if we need to cancel or place any new orders 114 | self.potentially_update().await; 115 | } else { 116 | error!( 117 | "could not get mid for asset {}: {all_mids:?}", 118 | self.asset.clone() 119 | ); 120 | } 121 | } 122 | Message::User(user_events) => { 123 | // We haven't seen the first mid price event yet, so just continue 124 | if self.latest_mid_price < 0.0 { 125 | continue; 126 | } 127 | let user_events = user_events.data; 128 | if let UserData::Fills(fills) = user_events { 129 | for fill in fills { 130 | let amount: f64 = fill.sz.parse().unwrap(); 131 | // Update our resting positions whenever we see a fill 132 | if fill.side.eq("B") { 133 | self.cur_position += amount; 134 | self.lower_resting.position -= amount; 135 | info!("Fill: bought {amount} {}", self.asset.clone()); 136 | } else { 137 | self.cur_position -= amount; 138 | self.upper_resting.position -= amount; 139 | info!("Fill: sold {amount} {}", self.asset.clone()); 140 | } 141 | } 142 | } 143 | // Check to see if we need to cancel or place any new orders 144 | self.potentially_update().await; 145 | } 146 | _ => { 147 | panic!("Unsupported message type"); 148 | } 149 | } 150 | } 151 | } 152 | 153 | async fn attempt_cancel(&self, asset: String, oid: u64) -> bool { 154 | let cancel = self 155 | .exchange_client 156 | .cancel(ClientCancelRequest { asset, oid }, None) 157 | .await; 158 | 159 | match cancel { 160 | Ok(cancel) => match cancel { 161 | ExchangeResponseStatus::Ok(cancel) => { 162 | if let Some(cancel) = cancel.data { 163 | if !cancel.statuses.is_empty() { 164 | match cancel.statuses[0].clone() { 165 | ExchangeDataStatus::Success => { 166 | return true; 167 | } 168 | ExchangeDataStatus::Error(e) => { 169 | error!("Error with cancelling: {e}") 170 | } 171 | _ => unreachable!(), 172 | } 173 | } else { 174 | error!("Exchange data statuses is empty when cancelling: {cancel:?}") 175 | } 176 | } else { 177 | error!("Exchange response data is empty when cancelling: {cancel:?}") 178 | } 179 | } 180 | ExchangeResponseStatus::Err(e) => error!("Error with cancelling: {e}"), 181 | }, 182 | Err(e) => error!("Error with cancelling: {e}"), 183 | } 184 | false 185 | } 186 | 187 | async fn place_order( 188 | &self, 189 | asset: String, 190 | amount: f64, 191 | price: f64, 192 | is_buy: bool, 193 | ) -> (f64, u64) { 194 | let order = self 195 | .exchange_client 196 | .order( 197 | ClientOrderRequest { 198 | asset, 199 | is_buy, 200 | reduce_only: false, 201 | limit_px: price, 202 | sz: amount, 203 | cloid: None, 204 | order_type: ClientOrder::Limit(ClientLimit { 205 | tif: "Gtc".to_string(), 206 | }), 207 | }, 208 | None, 209 | ) 210 | .await; 211 | match order { 212 | Ok(order) => match order { 213 | ExchangeResponseStatus::Ok(order) => { 214 | if let Some(order) = order.data { 215 | if !order.statuses.is_empty() { 216 | match order.statuses[0].clone() { 217 | ExchangeDataStatus::Filled(order) => { 218 | return (amount, order.oid); 219 | } 220 | ExchangeDataStatus::Resting(order) => { 221 | return (amount, order.oid); 222 | } 223 | ExchangeDataStatus::Error(e) => { 224 | error!("Error with placing order: {e}") 225 | } 226 | _ => unreachable!(), 227 | } 228 | } else { 229 | error!("Exchange data statuses is empty when placing order: {order:?}") 230 | } 231 | } else { 232 | error!("Exchange response data is empty when placing order: {order:?}") 233 | } 234 | } 235 | ExchangeResponseStatus::Err(e) => { 236 | error!("Error with placing order: {e}") 237 | } 238 | }, 239 | Err(e) => error!("Error with placing order: {e}"), 240 | } 241 | (0.0, 0) 242 | } 243 | 244 | async fn potentially_update(&mut self) { 245 | let half_spread = (self.latest_mid_price * self.half_spread as f64) / 10000.0; 246 | // Determine prices to target from the half spread 247 | let (lower_price, upper_price) = ( 248 | self.latest_mid_price - half_spread, 249 | self.latest_mid_price + half_spread, 250 | ); 251 | let (mut lower_price, mut upper_price) = ( 252 | truncate_float(lower_price, self.decimals, true), 253 | truncate_float(upper_price, self.decimals, false), 254 | ); 255 | 256 | // Rounding optimistically to make our market tighter might cause a weird edge case, so account for that 257 | if (lower_price - upper_price).abs() < EPSILON { 258 | lower_price = truncate_float(lower_price, self.decimals, false); 259 | upper_price = truncate_float(upper_price, self.decimals, true); 260 | } 261 | 262 | // Determine amounts we can put on the book without exceeding the max absolute position size 263 | let lower_order_amount = (self.max_absolute_position_size - self.cur_position) 264 | .min(self.target_liquidity) 265 | .max(0.0); 266 | 267 | let upper_order_amount = (self.max_absolute_position_size + self.cur_position) 268 | .min(self.target_liquidity) 269 | .max(0.0); 270 | 271 | // Determine if we need to cancel the resting order and put a new order up due to deviation 272 | let lower_change = (lower_order_amount - self.lower_resting.position).abs() > EPSILON 273 | || bps_diff(lower_price, self.lower_resting.price) > self.max_bps_diff; 274 | let upper_change = (upper_order_amount - self.upper_resting.position).abs() > EPSILON 275 | || bps_diff(upper_price, self.upper_resting.price) > self.max_bps_diff; 276 | 277 | // Consider cancelling 278 | // TODO: Don't block on cancels 279 | if self.lower_resting.oid != 0 && self.lower_resting.position > EPSILON && lower_change { 280 | let cancel = self 281 | .attempt_cancel(self.asset.clone(), self.lower_resting.oid) 282 | .await; 283 | // If we were unable to cancel, it means we got a fill, so wait until we receive that event to do anything 284 | if !cancel { 285 | return; 286 | } 287 | info!("Cancelled buy order: {:?}", self.lower_resting); 288 | } 289 | 290 | if self.upper_resting.oid != 0 && self.upper_resting.position > EPSILON && upper_change { 291 | let cancel = self 292 | .attempt_cancel(self.asset.clone(), self.upper_resting.oid) 293 | .await; 294 | if !cancel { 295 | return; 296 | } 297 | info!("Cancelled sell order: {:?}", self.upper_resting); 298 | } 299 | 300 | // Consider putting a new order up 301 | if lower_order_amount > EPSILON && lower_change { 302 | let (amount_resting, oid) = self 303 | .place_order(self.asset.clone(), lower_order_amount, lower_price, true) 304 | .await; 305 | 306 | self.lower_resting.oid = oid; 307 | self.lower_resting.position = amount_resting; 308 | self.lower_resting.price = lower_price; 309 | 310 | if amount_resting > EPSILON { 311 | info!( 312 | "Buy for {amount_resting} {} resting at {lower_price}", 313 | self.asset.clone() 314 | ); 315 | } 316 | } 317 | 318 | if upper_order_amount > EPSILON && upper_change { 319 | let (amount_resting, oid) = self 320 | .place_order(self.asset.clone(), upper_order_amount, upper_price, false) 321 | .await; 322 | self.upper_resting.oid = oid; 323 | self.upper_resting.position = amount_resting; 324 | self.upper_resting.price = upper_price; 325 | 326 | if amount_resting > EPSILON { 327 | info!( 328 | "Sell for {amount_resting} {} resting at {upper_price}", 329 | self.asset.clone() 330 | ); 331 | } 332 | } 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/meta.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use ethers::abi::ethereum_types::H128; 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(rename_all = "camelCase")] 57 | pub struct SpotAssetContext { 58 | pub day_ntl_vlm: String, 59 | pub mark_px: String, 60 | pub mid_px: Option, 61 | pub prev_day_px: String, 62 | pub circulating_supply: String, 63 | pub coin: String, 64 | } 65 | 66 | #[derive(Deserialize, Debug, Clone)] 67 | #[serde(rename_all = "camelCase")] 68 | pub struct AssetMeta { 69 | pub name: String, 70 | pub sz_decimals: u32, 71 | } 72 | 73 | #[derive(Deserialize, Debug, Clone)] 74 | #[serde(rename_all = "camelCase")] 75 | pub struct SpotAssetMeta { 76 | pub tokens: [usize; 2], 77 | pub name: String, 78 | pub index: usize, 79 | pub is_canonical: bool, 80 | } 81 | 82 | #[derive(Debug, Deserialize, Clone)] 83 | #[serde(rename_all = "camelCase")] 84 | pub struct TokenInfo { 85 | pub name: String, 86 | pub sz_decimals: u8, 87 | pub wei_decimals: u8, 88 | pub index: usize, 89 | pub token_id: H128, 90 | pub is_canonical: bool, 91 | } 92 | -------------------------------------------------------------------------------- /src/prelude.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::Error; 2 | 3 | pub(crate) type Result = std::result::Result; 4 | -------------------------------------------------------------------------------- /src/proxy_digest.rs: -------------------------------------------------------------------------------- 1 | // For synchronous signing. 2 | // Needed to duplicate our own copy because it wasn't possible to import from ethers-signers. 3 | use ethers::prelude::k256::{ 4 | elliptic_curve::generic_array::GenericArray, 5 | sha2::{ 6 | self, 7 | digest::{Output, OutputSizeUser}, 8 | Digest, 9 | }, 10 | }; 11 | use ethers::{ 12 | core::k256::ecdsa::signature::digest::{ 13 | FixedOutput, FixedOutputReset, HashMarker, Reset, Update, 14 | }, 15 | types::H256, 16 | }; 17 | 18 | pub(crate) type Sha256Proxy = ProxyDigest; 19 | 20 | #[derive(Clone)] 21 | pub(crate) enum ProxyDigest { 22 | Proxy(Output), 23 | Digest(D), 24 | } 25 | 26 | impl From for ProxyDigest 27 | where 28 | GenericArray::OutputSize>: Copy, 29 | { 30 | fn from(src: H256) -> Self { 31 | ProxyDigest::Proxy(*GenericArray::from_slice(src.as_bytes())) 32 | } 33 | } 34 | 35 | impl Default for ProxyDigest { 36 | fn default() -> Self { 37 | ProxyDigest::Digest(D::new()) 38 | } 39 | } 40 | 41 | impl Update for ProxyDigest { 42 | // we update only if we are digest 43 | fn update(&mut self, data: &[u8]) { 44 | match self { 45 | ProxyDigest::Digest(ref mut d) => { 46 | d.update(data); 47 | } 48 | ProxyDigest::Proxy(..) => { 49 | unreachable!("can not update if we are proxy"); 50 | } 51 | } 52 | } 53 | } 54 | 55 | impl HashMarker for ProxyDigest {} 56 | 57 | impl Reset for ProxyDigest { 58 | // make new one 59 | fn reset(&mut self) { 60 | *self = Self::default(); 61 | } 62 | } 63 | 64 | impl OutputSizeUser for ProxyDigest { 65 | // we default to the output of the original digest 66 | type OutputSize = ::OutputSize; 67 | } 68 | 69 | impl FixedOutput for ProxyDigest { 70 | fn finalize_into(self, out: &mut GenericArray) { 71 | match self { 72 | ProxyDigest::Digest(d) => { 73 | *out = d.finalize(); 74 | } 75 | ProxyDigest::Proxy(p) => { 76 | *out = p; 77 | } 78 | } 79 | } 80 | } 81 | 82 | impl FixedOutputReset for ProxyDigest { 83 | fn finalize_into_reset(&mut self, out: &mut Output) { 84 | let s = std::mem::take(self); 85 | Digest::finalize_into(s, out) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/req.rs: -------------------------------------------------------------------------------- 1 | use crate::{prelude::*, BaseUrl, Error}; 2 | use reqwest::{Client, Response}; 3 | use serde::Deserialize; 4 | 5 | #[derive(Deserialize, Debug)] 6 | struct ErrorData { 7 | data: String, 8 | code: u16, 9 | msg: String, 10 | } 11 | 12 | #[derive(Debug)] 13 | pub struct HttpClient { 14 | pub client: Client, 15 | pub base_url: String, 16 | } 17 | 18 | async fn parse_response(response: Response) -> Result { 19 | let status_code = response.status().as_u16(); 20 | let text = response 21 | .text() 22 | .await 23 | .map_err(|e| Error::GenericRequest(e.to_string()))?; 24 | 25 | if status_code < 400 { 26 | return Ok(text); 27 | } 28 | let error_data = serde_json::from_str::(&text); 29 | if (400..500).contains(&status_code) { 30 | let client_error = match error_data { 31 | Ok(error_data) => Error::ClientRequest { 32 | status_code, 33 | error_code: Some(error_data.code), 34 | error_message: error_data.msg, 35 | error_data: Some(error_data.data), 36 | }, 37 | Err(err) => Error::ClientRequest { 38 | status_code, 39 | error_message: text, 40 | error_code: None, 41 | error_data: Some(err.to_string()), 42 | }, 43 | }; 44 | return Err(client_error); 45 | } 46 | 47 | Err(Error::ServerRequest { 48 | status_code, 49 | error_message: text, 50 | }) 51 | } 52 | 53 | impl HttpClient { 54 | pub async fn post(&self, url_path: &'static str, data: String) -> Result { 55 | let full_url = format!("{}{url_path}", self.base_url); 56 | let request = self 57 | .client 58 | .post(full_url) 59 | .header("Content-Type", "application/json") 60 | .body(data) 61 | .build() 62 | .map_err(|e| Error::GenericRequest(e.to_string()))?; 63 | let result = self 64 | .client 65 | .execute(request) 66 | .await 67 | .map_err(|e| Error::GenericRequest(e.to_string()))?; 68 | parse_response(result).await 69 | } 70 | 71 | pub fn is_mainnet(&self) -> bool { 72 | self.base_url == BaseUrl::Mainnet.get_url() 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/signature/agent.rs: -------------------------------------------------------------------------------- 1 | use ethers::{ 2 | contract::{Eip712, EthAbiType}, 3 | types::H256, 4 | }; 5 | 6 | pub(crate) mod l1 { 7 | use super::*; 8 | #[derive(Debug, Eip712, Clone, EthAbiType)] 9 | #[eip712( 10 | name = "Exchange", 11 | version = "1", 12 | chain_id = 1337, 13 | verifying_contract = "0x0000000000000000000000000000000000000000" 14 | )] 15 | pub(crate) struct Agent { 16 | pub(crate) source: String, 17 | pub(crate) connection_id: H256, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/signature/create_signature.rs: -------------------------------------------------------------------------------- 1 | use ethers::{ 2 | core::k256::{elliptic_curve::FieldBytes, Secp256k1}, 3 | signers::LocalWallet, 4 | types::{transaction::eip712::Eip712, Signature, H256, U256}, 5 | }; 6 | 7 | use crate::{prelude::*, proxy_digest::Sha256Proxy, signature::agent::l1, Error}; 8 | 9 | pub(crate) fn sign_l1_action( 10 | wallet: &LocalWallet, 11 | connection_id: H256, 12 | is_mainnet: bool, 13 | ) -> Result { 14 | let source = if is_mainnet { "a" } else { "b" }.to_string(); 15 | sign_typed_data( 16 | &l1::Agent { 17 | source, 18 | connection_id, 19 | }, 20 | wallet, 21 | ) 22 | } 23 | 24 | pub(crate) fn sign_typed_data(payload: &T, wallet: &LocalWallet) -> Result { 25 | let encoded = payload 26 | .encode_eip712() 27 | .map_err(|e| Error::Eip712(e.to_string()))?; 28 | 29 | sign_hash(H256::from(encoded), wallet) 30 | } 31 | 32 | fn sign_hash(hash: H256, wallet: &LocalWallet) -> Result { 33 | let (sig, rec_id) = wallet 34 | .signer() 35 | .sign_digest_recoverable(Sha256Proxy::from(hash)) 36 | .map_err(|e| Error::SignatureFailure(e.to_string()))?; 37 | 38 | let v = u8::from(rec_id) as u64 + 27; 39 | 40 | let r_bytes: FieldBytes = sig.r().into(); 41 | let s_bytes: FieldBytes = sig.s().into(); 42 | let r = U256::from_big_endian(r_bytes.as_slice()); 43 | let s = U256::from_big_endian(s_bytes.as_slice()); 44 | 45 | Ok(Signature { r, s, v }) 46 | } 47 | 48 | #[cfg(test)] 49 | mod tests { 50 | use super::*; 51 | use crate::{UsdSend, Withdraw3}; 52 | use std::str::FromStr; 53 | 54 | fn get_wallet() -> Result { 55 | let priv_key = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e"; 56 | priv_key 57 | .parse::() 58 | .map_err(|e| Error::Wallet(e.to_string())) 59 | } 60 | 61 | #[test] 62 | fn test_sign_l1_action() -> Result<()> { 63 | let wallet = get_wallet()?; 64 | let connection_id = 65 | H256::from_str("0xde6c4037798a4434ca03cd05f00e3b803126221375cd1e7eaaaf041768be06eb") 66 | .map_err(|e| Error::GenericParse(e.to_string()))?; 67 | 68 | let expected_mainnet_sig = "fa8a41f6a3fa728206df80801a83bcbfbab08649cd34d9c0bfba7c7b2f99340f53a00226604567b98a1492803190d65a201d6805e5831b7044f17fd530aec7841c"; 69 | assert_eq!( 70 | sign_l1_action(&wallet, connection_id, true)?.to_string(), 71 | expected_mainnet_sig 72 | ); 73 | let expected_testnet_sig = "1713c0fc661b792a50e8ffdd59b637b1ed172d9a3aa4d801d9d88646710fb74b33959f4d075a7ccbec9f2374a6da21ffa4448d58d0413a0d335775f680a881431c"; 74 | assert_eq!( 75 | sign_l1_action(&wallet, connection_id, false)?.to_string(), 76 | expected_testnet_sig 77 | ); 78 | Ok(()) 79 | } 80 | 81 | #[test] 82 | fn test_sign_usd_transfer_action() -> Result<()> { 83 | let wallet = get_wallet()?; 84 | 85 | let usd_send = UsdSend { 86 | signature_chain_id: 421614.into(), 87 | hyperliquid_chain: "Testnet".to_string(), 88 | destination: "0x0D1d9635D0640821d15e323ac8AdADfA9c111414".to_string(), 89 | amount: "1".to_string(), 90 | time: 1690393044548, 91 | }; 92 | 93 | let expected_sig = "214d507bbdaebba52fa60928f904a8b2df73673e3baba6133d66fe846c7ef70451e82453a6d8db124e7ed6e60fa00d4b7c46e4d96cb2bd61fd81b6e8953cc9d21b"; 94 | assert_eq!( 95 | sign_typed_data(&usd_send, &wallet)?.to_string(), 96 | expected_sig 97 | ); 98 | Ok(()) 99 | } 100 | 101 | #[test] 102 | fn test_sign_withdraw_from_bridge_action() -> Result<()> { 103 | let wallet = get_wallet()?; 104 | 105 | let usd_send = Withdraw3 { 106 | signature_chain_id: 421614.into(), 107 | hyperliquid_chain: "Testnet".to_string(), 108 | destination: "0x0D1d9635D0640821d15e323ac8AdADfA9c111414".to_string(), 109 | amount: "1".to_string(), 110 | time: 1690393044548, 111 | }; 112 | 113 | let expected_sig = "b3172e33d2262dac2b4cb135ce3c167fda55dafa6c62213564ab728b9f9ba76b769a938e9f6d603dae7154c83bf5a4c3ebab81779dc2db25463a3ed663c82ae41c"; 114 | assert_eq!( 115 | sign_typed_data(&usd_send, &wallet)?.to_string(), 116 | expected_sig 117 | ); 118 | Ok(()) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /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/ws/message_types.rs: -------------------------------------------------------------------------------- 1 | use crate::ws::sub_structs::*; 2 | use serde::Deserialize; 3 | 4 | #[derive(Deserialize, Clone, Debug)] 5 | pub struct Trades { 6 | pub data: Vec, 7 | } 8 | 9 | #[derive(Deserialize, Clone, Debug)] 10 | pub struct L2Book { 11 | pub data: L2BookData, 12 | } 13 | 14 | #[derive(Deserialize, Clone, Debug)] 15 | pub struct AllMids { 16 | pub data: AllMidsData, 17 | } 18 | 19 | #[derive(Deserialize, Clone, Debug)] 20 | pub struct User { 21 | pub data: UserData, 22 | } 23 | 24 | #[derive(Deserialize, Clone, Debug)] 25 | pub struct UserFills { 26 | pub data: UserFillsData, 27 | } 28 | 29 | #[derive(Deserialize, Clone, Debug)] 30 | pub struct Candle { 31 | pub data: CandleData, 32 | } 33 | 34 | #[derive(Deserialize, Clone, Debug)] 35 | pub struct OrderUpdates { 36 | pub data: Vec, 37 | } 38 | 39 | #[derive(Deserialize, Clone, Debug)] 40 | pub struct UserFundings { 41 | pub data: UserFundingsData, 42 | } 43 | 44 | #[derive(Deserialize, Clone, Debug)] 45 | pub struct UserNonFundingLedgerUpdates { 46 | pub data: UserNonFundingLedgerUpdatesData, 47 | } 48 | 49 | #[derive(Deserialize, Clone, Debug)] 50 | pub struct Notification { 51 | pub data: NotificationData, 52 | } 53 | 54 | #[derive(Deserialize, Clone, Debug)] 55 | pub struct WebData2 { 56 | pub data: WebData2Data, 57 | } 58 | 59 | #[derive(Deserialize, Clone, Debug)] 60 | pub struct ActiveAssetCtx { 61 | pub data: ActiveAssetCtxData, 62 | } 63 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/ws/sub_structs.rs: -------------------------------------------------------------------------------- 1 | use ethers::types::H160; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::HashMap; 4 | 5 | #[derive(Deserialize, Clone, Debug)] 6 | pub struct Trade { 7 | pub coin: String, 8 | pub side: String, 9 | pub px: String, 10 | pub sz: String, 11 | pub time: u64, 12 | pub hash: String, 13 | pub tid: u64, 14 | } 15 | 16 | #[derive(Deserialize, Clone, Debug)] 17 | pub struct BookLevel { 18 | pub px: String, 19 | pub sz: String, 20 | pub n: u64, 21 | } 22 | 23 | #[derive(Deserialize, Clone, Debug)] 24 | pub struct L2BookData { 25 | pub coin: String, 26 | pub time: u64, 27 | pub levels: Vec>, 28 | } 29 | 30 | #[derive(Deserialize, Clone, Debug)] 31 | pub struct AllMidsData { 32 | pub mids: HashMap, 33 | } 34 | 35 | #[derive(Deserialize, Clone, Debug)] 36 | #[serde(rename_all = "camelCase")] 37 | pub struct TradeInfo { 38 | pub coin: String, 39 | pub side: String, 40 | pub px: String, 41 | pub sz: String, 42 | pub time: u64, 43 | pub hash: String, 44 | pub start_position: String, 45 | pub dir: String, 46 | pub closed_pnl: String, 47 | pub oid: u64, 48 | pub cloid: Option, 49 | pub crossed: bool, 50 | pub fee: String, 51 | pub fee_token: String, 52 | pub tid: u64, 53 | } 54 | 55 | #[derive(Deserialize, Clone, Debug)] 56 | #[serde(rename_all = "camelCase")] 57 | pub struct UserFillsData { 58 | pub is_snapshot: Option, 59 | pub user: H160, 60 | pub fills: Vec, 61 | } 62 | 63 | #[derive(Deserialize, Clone, Debug)] 64 | #[serde(rename_all = "camelCase")] 65 | pub enum UserData { 66 | Fills(Vec), 67 | Funding(UserFunding), 68 | Liquidation(Liquidation), 69 | NonUserCancel(Vec), 70 | } 71 | 72 | #[derive(Deserialize, Clone, Debug)] 73 | pub struct Liquidation { 74 | pub lid: u64, 75 | pub liquidator: String, 76 | pub liquidated_user: String, 77 | pub liquidated_ntl_pos: String, 78 | pub liquidated_account_value: String, 79 | } 80 | 81 | #[derive(Deserialize, Clone, Debug)] 82 | pub struct NonUserCancel { 83 | pub coin: String, 84 | pub oid: u64, 85 | } 86 | 87 | #[derive(Deserialize, Clone, Debug)] 88 | pub struct CandleData { 89 | #[serde(rename = "T")] 90 | pub time_close: u64, 91 | #[serde(rename = "c")] 92 | pub close: String, 93 | #[serde(rename = "h")] 94 | pub high: String, 95 | #[serde(rename = "i")] 96 | pub interval: String, 97 | #[serde(rename = "l")] 98 | pub low: String, 99 | #[serde(rename = "n")] 100 | pub num_trades: u64, 101 | #[serde(rename = "o")] 102 | pub open: String, 103 | #[serde(rename = "s")] 104 | pub coin: String, 105 | #[serde(rename = "t")] 106 | pub time_open: u64, 107 | #[serde(rename = "v")] 108 | pub volume: String, 109 | } 110 | 111 | #[derive(Deserialize, Clone, Debug)] 112 | #[serde(rename_all = "camelCase")] 113 | pub struct OrderUpdate { 114 | pub order: BasicOrder, 115 | pub status: String, 116 | pub status_timestamp: u64, 117 | } 118 | 119 | #[derive(Deserialize, Clone, Debug)] 120 | #[serde(rename_all = "camelCase")] 121 | pub struct BasicOrder { 122 | pub coin: String, 123 | pub side: String, 124 | pub limit_px: String, 125 | pub sz: String, 126 | pub oid: u64, 127 | pub timestamp: u64, 128 | pub orig_sz: String, 129 | pub cloid: Option, 130 | } 131 | 132 | #[derive(Deserialize, Clone, Debug)] 133 | #[serde(rename_all = "camelCase")] 134 | pub struct UserFundingsData { 135 | pub is_snapshot: Option, 136 | pub user: H160, 137 | pub fundings: Vec, 138 | } 139 | 140 | #[derive(Deserialize, Clone, Debug)] 141 | #[serde(rename_all = "camelCase")] 142 | pub struct UserFunding { 143 | pub time: u64, 144 | pub coin: String, 145 | pub usdc: String, 146 | pub szi: String, 147 | pub funding_rate: String, 148 | } 149 | 150 | #[derive(Deserialize, Clone, Debug)] 151 | #[serde(rename_all = "camelCase")] 152 | pub struct UserNonFundingLedgerUpdatesData { 153 | pub is_snapshot: Option, 154 | pub user: H160, 155 | pub non_funding_ledger_updates: Vec, 156 | } 157 | 158 | #[derive(Deserialize, Clone, Debug)] 159 | pub struct LedgerUpdateData { 160 | pub time: u64, 161 | pub hash: String, 162 | pub delta: LedgerUpdate, 163 | } 164 | 165 | #[derive(Deserialize, Clone, Debug)] 166 | #[serde(rename_all = "camelCase")] 167 | #[serde(tag = "type")] 168 | pub enum LedgerUpdate { 169 | Deposit(Deposit), 170 | Withdraw(Withdraw), 171 | InternalTransfer(InternalTransfer), 172 | SubAccountTransfer(SubAccountTransfer), 173 | LedgerLiquidation(LedgerLiquidation), 174 | VaultDeposit(VaultDelta), 175 | VaultCreate(VaultDelta), 176 | VaultDistribution(VaultDelta), 177 | VaultWithdraw(VaultWithdraw), 178 | VaultLeaderCommission(VaultLeaderCommission), 179 | AccountClassTransfer(AccountClassTransfer), 180 | SpotTransfer(SpotTransfer), 181 | SpotGenesis(SpotGenesis), 182 | } 183 | 184 | #[derive(Deserialize, Clone, Debug)] 185 | pub struct Deposit { 186 | pub usdc: String, 187 | } 188 | 189 | #[derive(Deserialize, Clone, Debug)] 190 | pub struct Withdraw { 191 | pub usdc: String, 192 | pub nonce: u64, 193 | pub fee: String, 194 | } 195 | 196 | #[derive(Deserialize, Clone, Debug)] 197 | pub struct InternalTransfer { 198 | pub usdc: String, 199 | pub user: H160, 200 | pub destination: H160, 201 | pub fee: String, 202 | } 203 | 204 | #[derive(Deserialize, Clone, Debug)] 205 | pub struct SubAccountTransfer { 206 | pub usdc: String, 207 | pub user: H160, 208 | pub destination: H160, 209 | } 210 | 211 | #[derive(Deserialize, Clone, Debug)] 212 | #[serde(rename_all = "camelCase")] 213 | pub struct LedgerLiquidation { 214 | pub account_value: u64, 215 | pub leverage_type: String, 216 | pub liquidated_positions: Vec, 217 | } 218 | 219 | #[derive(Deserialize, Clone, Debug)] 220 | pub struct LiquidatedPosition { 221 | pub coin: String, 222 | pub szi: String, 223 | } 224 | 225 | #[derive(Deserialize, Clone, Debug)] 226 | pub struct VaultDelta { 227 | pub vault: H160, 228 | pub usdc: String, 229 | } 230 | 231 | #[derive(Deserialize, Clone, Debug)] 232 | #[serde(rename_all = "camelCase")] 233 | pub struct VaultWithdraw { 234 | pub vault: H160, 235 | pub user: H160, 236 | pub requested_usd: String, 237 | pub commission: String, 238 | pub closing_cost: String, 239 | pub basis: String, 240 | pub net_withdrawn_usd: String, 241 | } 242 | 243 | #[derive(Deserialize, Clone, Debug)] 244 | pub struct VaultLeaderCommission { 245 | pub user: H160, 246 | pub usdc: String, 247 | } 248 | 249 | #[derive(Deserialize, Clone, Debug)] 250 | #[serde(rename_all = "camelCase")] 251 | pub struct AccountClassTransfer { 252 | pub usdc: String, 253 | pub to_perp: bool, 254 | } 255 | 256 | #[derive(Deserialize, Clone, Debug)] 257 | #[serde(rename_all = "camelCase")] 258 | pub struct SpotTransfer { 259 | pub token: String, 260 | pub amount: String, 261 | pub usdc_value: String, 262 | pub user: H160, 263 | pub destination: H160, 264 | pub fee: String, 265 | } 266 | 267 | #[derive(Deserialize, Clone, Debug)] 268 | pub struct SpotGenesis { 269 | pub token: String, 270 | pub amount: String, 271 | } 272 | 273 | #[derive(Deserialize, Clone, Debug)] 274 | pub struct NotificationData { 275 | pub notification: String, 276 | } 277 | 278 | #[derive(Deserialize, Clone, Debug)] 279 | #[serde(rename_all = "camelCase")] 280 | pub struct WebData2Data { 281 | pub user: H160, 282 | } 283 | 284 | #[derive(Deserialize, Clone, Debug)] 285 | #[serde(rename_all = "camelCase")] 286 | pub struct ActiveAssetCtxData { 287 | pub coin: String, 288 | pub ctx: AssetCtx, 289 | } 290 | 291 | #[derive(Deserialize, Serialize, Clone, Debug)] 292 | #[serde(rename_all = "camelCase")] 293 | #[serde(untagged)] 294 | pub enum AssetCtx { 295 | Perps(PerpsAssetCtx), 296 | Spot(SpotAssetCtx), 297 | } 298 | 299 | #[derive(Deserialize, Serialize, Clone, Debug)] 300 | #[serde(rename_all = "camelCase")] 301 | pub struct SharedAssetCtx { 302 | pub day_ntl_vlm: String, 303 | pub prev_day_px: String, 304 | pub mark_px: String, 305 | pub mid_px: Option, 306 | } 307 | 308 | #[derive(Deserialize, Serialize, Clone, Debug)] 309 | #[serde(rename_all = "camelCase")] 310 | pub struct PerpsAssetCtx { 311 | #[serde(flatten)] 312 | pub shared: SharedAssetCtx, 313 | pub funding: String, 314 | pub open_interest: String, 315 | pub oracle_px: String, 316 | } 317 | 318 | #[derive(Deserialize, Serialize, Clone, Debug)] 319 | #[serde(rename_all = "camelCase")] 320 | pub struct SpotAssetCtx { 321 | #[serde(flatten)] 322 | pub shared: SharedAssetCtx, 323 | pub circulating_supply: String, 324 | } 325 | -------------------------------------------------------------------------------- /src/ws/ws_manager.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | prelude::*, 3 | ws::message_types::{AllMids, Candle, L2Book, OrderUpdates, Trades, User}, 4 | ActiveAssetCtx, Error, Notification, UserFills, UserFundings, UserNonFundingLedgerUpdates, 5 | WebData2, 6 | }; 7 | use futures_util::{stream::SplitSink, SinkExt, StreamExt}; 8 | use log::{error, info, warn}; 9 | use serde::{Deserialize, Serialize}; 10 | use std::{ 11 | borrow::BorrowMut, 12 | collections::HashMap, 13 | ops::DerefMut, 14 | sync::{ 15 | atomic::{AtomicBool, Ordering}, 16 | Arc, 17 | }, 18 | time::Duration, 19 | }; 20 | use tokio::{ 21 | net::TcpStream, 22 | spawn, 23 | sync::{mpsc::UnboundedSender, Mutex}, 24 | time, 25 | }; 26 | use tokio_tungstenite::{ 27 | connect_async, 28 | tungstenite::{self, protocol}, 29 | MaybeTlsStream, WebSocketStream, 30 | }; 31 | 32 | use ethers::types::H160; 33 | 34 | #[derive(Debug)] 35 | struct SubscriptionData { 36 | sending_channel: UnboundedSender, 37 | subscription_id: u32, 38 | id: String, 39 | } 40 | #[derive(Debug)] 41 | pub(crate) struct WsManager { 42 | stop_flag: Arc, 43 | writer: Arc>, protocol::Message>>>, 44 | subscriptions: Arc>>>, 45 | subscription_id: u32, 46 | subscription_identifiers: HashMap, 47 | } 48 | 49 | #[derive(Serialize, Deserialize, Debug)] 50 | #[serde(tag = "type")] 51 | #[serde(rename_all = "camelCase")] 52 | pub enum Subscription { 53 | AllMids, 54 | Notification { user: H160 }, 55 | WebData2 { user: H160 }, 56 | Candle { coin: String, interval: String }, 57 | L2Book { coin: String }, 58 | Trades { coin: String }, 59 | OrderUpdates { user: H160 }, 60 | UserEvents { user: H160 }, 61 | UserFills { user: H160 }, 62 | UserFundings { user: H160 }, 63 | UserNonFundingLedgerUpdates { user: H160 }, 64 | ActiveAssetCtx { coin: String }, 65 | } 66 | 67 | #[derive(Deserialize, Clone, Debug)] 68 | #[serde(tag = "channel")] 69 | #[serde(rename_all = "camelCase")] 70 | pub enum Message { 71 | NoData, 72 | HyperliquidError(String), 73 | AllMids(AllMids), 74 | Trades(Trades), 75 | L2Book(L2Book), 76 | User(User), 77 | UserFills(UserFills), 78 | Candle(Candle), 79 | SubscriptionResponse, 80 | OrderUpdates(OrderUpdates), 81 | UserFundings(UserFundings), 82 | UserNonFundingLedgerUpdates(UserNonFundingLedgerUpdates), 83 | Notification(Notification), 84 | WebData2(WebData2), 85 | ActiveAssetCtx(ActiveAssetCtx), 86 | Pong, 87 | } 88 | 89 | #[derive(Serialize)] 90 | pub(crate) struct SubscriptionSendData<'a> { 91 | method: &'static str, 92 | subscription: &'a serde_json::Value, 93 | } 94 | 95 | #[derive(Serialize)] 96 | pub(crate) struct Ping { 97 | method: &'static str, 98 | } 99 | 100 | impl WsManager { 101 | const SEND_PING_INTERVAL: u64 = 50; 102 | 103 | pub(crate) async fn new(url: String, reconnect: bool) -> Result { 104 | let stop_flag = Arc::new(AtomicBool::new(false)); 105 | 106 | let (writer, mut reader) = Self::connect(&url).await?.split(); 107 | let writer = Arc::new(Mutex::new(writer)); 108 | 109 | let subscriptions_map: HashMap> = HashMap::new(); 110 | let subscriptions = Arc::new(Mutex::new(subscriptions_map)); 111 | let subscriptions_copy = Arc::clone(&subscriptions); 112 | 113 | { 114 | let writer = writer.clone(); 115 | let stop_flag = Arc::clone(&stop_flag); 116 | let reader_fut = async move { 117 | while !stop_flag.load(Ordering::Relaxed) { 118 | if let Some(data) = reader.next().await { 119 | if let Err(err) = 120 | WsManager::parse_and_send_data(data, &subscriptions_copy).await 121 | { 122 | error!("Error processing data received by WsManager reader: {err}"); 123 | } 124 | } else { 125 | warn!("WsManager disconnected"); 126 | if let Err(err) = WsManager::send_to_all_subscriptions( 127 | &subscriptions_copy, 128 | Message::NoData, 129 | ) 130 | .await 131 | { 132 | warn!("Error sending disconnection notification err={err}"); 133 | } 134 | if reconnect { 135 | // Always sleep for 1 second before attempting to reconnect so it does not spin during reconnecting. This could be enhanced with exponential backoff. 136 | tokio::time::sleep(Duration::from_secs(1)).await; 137 | info!("WsManager attempting to reconnect"); 138 | match Self::connect(&url).await { 139 | Ok(ws) => { 140 | let (new_writer, new_reader) = ws.split(); 141 | reader = new_reader; 142 | let mut writer_guard = writer.lock().await; 143 | *writer_guard = new_writer; 144 | for (identifier, v) in subscriptions_copy.lock().await.iter() { 145 | // TODO should these special keys be removed and instead use the simpler direct identifier mapping? 146 | if identifier.eq("userEvents") 147 | || identifier.eq("orderUpdates") 148 | { 149 | for subscription_data in v { 150 | if let Err(err) = Self::subscribe( 151 | writer_guard.deref_mut(), 152 | &subscription_data.id, 153 | ) 154 | .await 155 | { 156 | error!( 157 | "Could not resubscribe {identifier}: {err}" 158 | ); 159 | } 160 | } 161 | } else if let Err(err) = 162 | Self::subscribe(writer_guard.deref_mut(), identifier) 163 | .await 164 | { 165 | error!("Could not resubscribe correctly {identifier}: {err}"); 166 | } 167 | } 168 | info!("WsManager reconnect finished"); 169 | } 170 | Err(err) => error!("Could not connect to websocket {err}"), 171 | } 172 | } else { 173 | error!("WsManager reconnection disabled. Will not reconnect and exiting reader task."); 174 | break; 175 | } 176 | } 177 | } 178 | warn!("ws message reader task stopped"); 179 | }; 180 | spawn(reader_fut); 181 | } 182 | 183 | { 184 | let stop_flag = Arc::clone(&stop_flag); 185 | let writer = Arc::clone(&writer); 186 | let ping_fut = async move { 187 | while !stop_flag.load(Ordering::Relaxed) { 188 | match serde_json::to_string(&Ping { method: "ping" }) { 189 | Ok(payload) => { 190 | let mut writer = writer.lock().await; 191 | if let Err(err) = writer.send(protocol::Message::Text(payload)).await { 192 | error!("Error pinging server: {err}") 193 | } 194 | } 195 | Err(err) => error!("Error serializing ping message: {err}"), 196 | } 197 | time::sleep(Duration::from_secs(Self::SEND_PING_INTERVAL)).await; 198 | } 199 | warn!("ws ping task stopped"); 200 | }; 201 | spawn(ping_fut); 202 | } 203 | 204 | Ok(WsManager { 205 | stop_flag, 206 | writer, 207 | subscriptions, 208 | subscription_id: 0, 209 | subscription_identifiers: HashMap::new(), 210 | }) 211 | } 212 | 213 | async fn connect(url: &str) -> Result>> { 214 | Ok(connect_async(url) 215 | .await 216 | .map_err(|e| Error::Websocket(e.to_string()))? 217 | .0) 218 | } 219 | 220 | fn get_identifier(message: &Message) -> Result { 221 | match message { 222 | Message::AllMids(_) => serde_json::to_string(&Subscription::AllMids) 223 | .map_err(|e| Error::JsonParse(e.to_string())), 224 | Message::User(_) => Ok("userEvents".to_string()), 225 | Message::UserFills(fills) => serde_json::to_string(&Subscription::UserFills { 226 | user: fills.data.user, 227 | }) 228 | .map_err(|e| Error::JsonParse(e.to_string())), 229 | Message::Trades(trades) => { 230 | if trades.data.is_empty() { 231 | Ok(String::default()) 232 | } else { 233 | serde_json::to_string(&Subscription::Trades { 234 | coin: trades.data[0].coin.clone(), 235 | }) 236 | .map_err(|e| Error::JsonParse(e.to_string())) 237 | } 238 | } 239 | Message::L2Book(l2_book) => serde_json::to_string(&Subscription::L2Book { 240 | coin: l2_book.data.coin.clone(), 241 | }) 242 | .map_err(|e| Error::JsonParse(e.to_string())), 243 | Message::Candle(candle) => serde_json::to_string(&Subscription::Candle { 244 | coin: candle.data.coin.clone(), 245 | interval: candle.data.interval.clone(), 246 | }) 247 | .map_err(|e| Error::JsonParse(e.to_string())), 248 | Message::OrderUpdates(_) => Ok("orderUpdates".to_string()), 249 | Message::UserFundings(fundings) => serde_json::to_string(&Subscription::UserFundings { 250 | user: fundings.data.user, 251 | }) 252 | .map_err(|e| Error::JsonParse(e.to_string())), 253 | Message::UserNonFundingLedgerUpdates(user_non_funding_ledger_updates) => { 254 | serde_json::to_string(&Subscription::UserNonFundingLedgerUpdates { 255 | user: user_non_funding_ledger_updates.data.user, 256 | }) 257 | .map_err(|e| Error::JsonParse(e.to_string())) 258 | } 259 | Message::Notification(_) => Ok("notification".to_string()), 260 | Message::WebData2(web_data2) => serde_json::to_string(&Subscription::WebData2 { 261 | user: web_data2.data.user, 262 | }) 263 | .map_err(|e| Error::JsonParse(e.to_string())), 264 | Message::ActiveAssetCtx(active_asset_ctx) => { 265 | serde_json::to_string(&Subscription::ActiveAssetCtx { 266 | coin: active_asset_ctx.data.coin.clone(), 267 | }) 268 | .map_err(|e| Error::JsonParse(e.to_string())) 269 | } 270 | Message::SubscriptionResponse | Message::Pong => Ok(String::default()), 271 | Message::NoData => Ok("".to_string()), 272 | Message::HyperliquidError(err) => Ok(format!("hyperliquid error: {err:?}")), 273 | } 274 | } 275 | 276 | async fn parse_and_send_data( 277 | data: std::result::Result, 278 | subscriptions: &Arc>>>, 279 | ) -> Result<()> { 280 | match data { 281 | Ok(data) => match data.into_text() { 282 | Ok(data) => { 283 | if !data.starts_with('{') { 284 | return Ok(()); 285 | } 286 | let message = serde_json::from_str::(&data) 287 | .map_err(|e| Error::JsonParse(e.to_string()))?; 288 | let identifier = WsManager::get_identifier(&message)?; 289 | if identifier.is_empty() { 290 | return Ok(()); 291 | } 292 | 293 | let mut subscriptions = subscriptions.lock().await; 294 | let mut res = Ok(()); 295 | if let Some(subscription_datas) = subscriptions.get_mut(&identifier) { 296 | for subscription_data in subscription_datas { 297 | if let Err(e) = subscription_data 298 | .sending_channel 299 | .send(message.clone()) 300 | .map_err(|e| Error::WsSend(e.to_string())) 301 | { 302 | res = Err(e); 303 | } 304 | } 305 | } 306 | res 307 | } 308 | Err(err) => { 309 | let error = Error::ReaderTextConversion(err.to_string()); 310 | Ok(WsManager::send_to_all_subscriptions( 311 | subscriptions, 312 | Message::HyperliquidError(error.to_string()), 313 | ) 314 | .await?) 315 | } 316 | }, 317 | Err(err) => { 318 | let error = Error::GenericReader(err.to_string()); 319 | Ok(WsManager::send_to_all_subscriptions( 320 | subscriptions, 321 | Message::HyperliquidError(error.to_string()), 322 | ) 323 | .await?) 324 | } 325 | } 326 | } 327 | 328 | async fn send_to_all_subscriptions( 329 | subscriptions: &Arc>>>, 330 | message: Message, 331 | ) -> Result<()> { 332 | let mut subscriptions = subscriptions.lock().await; 333 | let mut res = Ok(()); 334 | for subscription_datas in subscriptions.values_mut() { 335 | for subscription_data in subscription_datas { 336 | if let Err(e) = subscription_data 337 | .sending_channel 338 | .send(message.clone()) 339 | .map_err(|e| Error::WsSend(e.to_string())) 340 | { 341 | res = Err(e); 342 | } 343 | } 344 | } 345 | res 346 | } 347 | 348 | async fn send_subscription_data( 349 | method: &'static str, 350 | writer: &mut SplitSink>, protocol::Message>, 351 | identifier: &str, 352 | ) -> Result<()> { 353 | let payload = serde_json::to_string(&SubscriptionSendData { 354 | method, 355 | subscription: &serde_json::from_str::(identifier) 356 | .map_err(|e| Error::JsonParse(e.to_string()))?, 357 | }) 358 | .map_err(|e| Error::JsonParse(e.to_string()))?; 359 | 360 | writer 361 | .send(protocol::Message::Text(payload)) 362 | .await 363 | .map_err(|e| Error::Websocket(e.to_string()))?; 364 | Ok(()) 365 | } 366 | 367 | async fn subscribe( 368 | writer: &mut SplitSink>, protocol::Message>, 369 | identifier: &str, 370 | ) -> Result<()> { 371 | Self::send_subscription_data("subscribe", writer, identifier).await 372 | } 373 | 374 | async fn unsubscribe( 375 | writer: &mut SplitSink>, protocol::Message>, 376 | identifier: &str, 377 | ) -> Result<()> { 378 | Self::send_subscription_data("unsubscribe", writer, identifier).await 379 | } 380 | 381 | pub(crate) async fn add_subscription( 382 | &mut self, 383 | identifier: String, 384 | sending_channel: UnboundedSender, 385 | ) -> Result { 386 | let mut subscriptions = self.subscriptions.lock().await; 387 | 388 | let identifier_entry = if let Subscription::UserEvents { user: _ } = 389 | serde_json::from_str::(&identifier) 390 | .map_err(|e| Error::JsonParse(e.to_string()))? 391 | { 392 | "userEvents".to_string() 393 | } else if let Subscription::OrderUpdates { user: _ } = 394 | serde_json::from_str::(&identifier) 395 | .map_err(|e| Error::JsonParse(e.to_string()))? 396 | { 397 | "orderUpdates".to_string() 398 | } else { 399 | identifier.clone() 400 | }; 401 | let subscriptions = subscriptions 402 | .entry(identifier_entry.clone()) 403 | .or_insert(Vec::new()); 404 | 405 | if !subscriptions.is_empty() && identifier_entry.eq("userEvents") { 406 | return Err(Error::UserEvents); 407 | } 408 | 409 | if subscriptions.is_empty() { 410 | Self::subscribe(self.writer.lock().await.borrow_mut(), identifier.as_str()).await?; 411 | } 412 | 413 | let subscription_id = self.subscription_id; 414 | self.subscription_identifiers 415 | .insert(subscription_id, identifier.clone()); 416 | subscriptions.push(SubscriptionData { 417 | sending_channel, 418 | subscription_id, 419 | id: identifier, 420 | }); 421 | 422 | self.subscription_id += 1; 423 | Ok(subscription_id) 424 | } 425 | 426 | pub(crate) async fn remove_subscription(&mut self, subscription_id: u32) -> Result<()> { 427 | let identifier = self 428 | .subscription_identifiers 429 | .get(&subscription_id) 430 | .ok_or(Error::SubscriptionNotFound)? 431 | .clone(); 432 | 433 | let identifier_entry = if let Subscription::UserEvents { user: _ } = 434 | serde_json::from_str::(&identifier) 435 | .map_err(|e| Error::JsonParse(e.to_string()))? 436 | { 437 | "userEvents".to_string() 438 | } else if let Subscription::OrderUpdates { user: _ } = 439 | serde_json::from_str::(&identifier) 440 | .map_err(|e| Error::JsonParse(e.to_string()))? 441 | { 442 | "orderUpdates".to_string() 443 | } else { 444 | identifier.clone() 445 | }; 446 | 447 | self.subscription_identifiers.remove(&subscription_id); 448 | 449 | let mut subscriptions = self.subscriptions.lock().await; 450 | 451 | let subscriptions = subscriptions 452 | .get_mut(&identifier_entry) 453 | .ok_or(Error::SubscriptionNotFound)?; 454 | let index = subscriptions 455 | .iter() 456 | .position(|subscription_data| subscription_data.subscription_id == subscription_id) 457 | .ok_or(Error::SubscriptionNotFound)?; 458 | subscriptions.remove(index); 459 | 460 | if subscriptions.is_empty() { 461 | Self::unsubscribe(self.writer.lock().await.borrow_mut(), identifier.as_str()).await?; 462 | } 463 | Ok(()) 464 | } 465 | } 466 | 467 | impl Drop for WsManager { 468 | fn drop(&mut self) { 469 | self.stop_flag.store(true, Ordering::Relaxed); 470 | } 471 | } 472 | --------------------------------------------------------------------------------