hyperliquid_ws_post/
ws_post.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Minimal WS post example: info (l2Book) and an order action with real signing.
17
18use std::{env, time::Duration};
19
20use nautilus_hyperliquid::{
21    common::{consts::ws_url, credential::EvmPrivateKey},
22    signing::{HyperliquidActionType, HyperliquidEip712Signer, SignRequest, TimeNonce},
23    websocket::{
24        client::HyperliquidWebSocketClient,
25        messages::{ActionPayload, ActionRequest, SignatureData, TimeInForceRequest},
26        post::{Grouping, OrderBuilder},
27    },
28};
29use tracing::level_filters::LevelFilter;
30use tracing_subscriber::{EnvFilter, fmt};
31
32#[tokio::main]
33async fn main() -> Result<(), Box<dyn std::error::Error>> {
34    // Structured logging with env-controlled filter (e.g. RUST_LOG=debug)
35    let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
36    fmt()
37        .with_env_filter(env_filter)
38        .with_max_level(LevelFilter::INFO)
39        .init();
40
41    let args: Vec<String> = env::args().collect();
42    let testnet = args.get(1).is_some_and(|s| s == "testnet");
43    let ws_url = ws_url(testnet);
44
45    tracing::info!(component = "ws_post", %ws_url, ?testnet, "connecting");
46    let client = HyperliquidWebSocketClient::connect(ws_url).await?;
47    tracing::info!(component = "ws_post", "websocket connected");
48
49    let book = client.info_l2_book("BTC", Duration::from_secs(2)).await?;
50    let best_bid = book
51        .levels
52        .first()
53        .and_then(|bids| bids.first())
54        .map(|l| l.px.clone())
55        .unwrap_or_default();
56    let best_ask = book
57        .levels
58        .get(1)
59        .and_then(|asks| asks.first())
60        .map(|l| l.px.clone())
61        .unwrap_or_default();
62    tracing::info!(component = "ws_post", best_bid = %best_bid, best_ask = %best_ask, "BTC top of book");
63
64    // Only attempt the action when explicitly requested (HYPERLIQUID_SEND=1).
65    let should_send = env::var("HYPERLIQUID_SEND")
66        .map(|v| v == "1")
67        .unwrap_or(false);
68    if !should_send {
69        tracing::warn!(
70            component = "ws_post",
71            "skipping action: set HYPERLIQUID_SEND=1 to send the order"
72        );
73        return Ok(());
74    }
75
76    if best_bid.is_empty() {
77        tracing::warn!(
78            component = "ws_post",
79            "no best bid available; aborting action"
80        );
81        return Ok(());
82    }
83
84    // === ACTION (stub): place a post-only limit (requires real signature!) ===
85    let action: ActionRequest = OrderBuilder::new()
86        .grouping(Grouping::Na)
87        .push_limit(
88            /*asset*/ 0, // BTC (adapter maps 0 → BTC)
89            /*is_buy*/ true, // buy
90            /*px*/ best_bid.clone(), // price from book
91            /*sz*/ "0.001", // size
92            /*reduce_only*/ false,
93            TimeInForceRequest::Alo, // post-only
94            Some("test-cloid-1".to_string()),
95        )
96        .build();
97
98    // Get private key from environment for signing
99    let private_key_str = env::var("HYPERLIQUID_PK").map_err(
100        |_| "HYPERLIQUID_PK environment variable not set. Example: export HYPERLIQUID_PK=0x...",
101    )?;
102    let private_key = EvmPrivateKey::new(private_key_str)?;
103    let signer = HyperliquidEip712Signer::new(private_key);
104
105    // Convert action to JSON for signing
106    let action_json = serde_json::to_value(&action)?;
107
108    // Get current nonce (Unix timestamp in milliseconds)
109    let nonce = TimeNonce::now_millis();
110
111    // Sign the action
112    let sign_request = SignRequest {
113        action: action_json,
114        time_nonce: nonce,
115        action_type: HyperliquidActionType::UserSigned,
116    };
117
118    let signature_bundle = signer.sign(&sign_request)?;
119
120    // Parse signature into r, s, v components
121    // Format is: 0x + r(64 hex) + s(64 hex) + v(2 hex) = 132 chars total
122    let sig = signature_bundle.signature;
123    if sig.len() != 132 || !sig.starts_with("0x") {
124        return Err(format!("Invalid signature format: {}", sig).into());
125    }
126
127    let signature = SignatureData {
128        r: format!("0x{}", &sig[2..66]),    // Extract r component
129        s: format!("0x{}", &sig[66..130]),  // Extract s component
130        v: format!("0x{}", &sig[130..132]), // Extract v component
131    };
132
133    tracing::info!(component = "ws_post", "action signed successfully");
134
135    let payload = ActionPayload {
136        action,
137        nonce: nonce.as_millis() as u64,
138        signature,
139        vault_address: None,
140    };
141
142    match client
143        .post_action_raw(payload, Duration::from_secs(2))
144        .await
145    {
146        Ok(resp) => tracing::info!(component = "ws_post", ?resp, "action response (success)"),
147        Err(e) => {
148            tracing::warn!(component = "ws_post", error = %e, "action failed")
149        }
150    }
151
152    Ok(())
153}