Skip to main content

nautilus_hyperliquid/common/
builder_fee.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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//! Builder fee approval and verification functionality.
17//!
18//! Note: Hyperliquid uses non-standard EIP-712 type names with colons
19//! (e.g., "HyperliquidTransaction:ApproveBuilderFee") which cannot be
20//! represented using alloy's `sol!` macro. The struct hash is computed
21//! manually while the domain uses alloy's `Eip712Domain`.
22
23use std::{
24    collections::HashMap,
25    env,
26    io::{self, Write},
27    str::FromStr,
28    sync::{
29        Arc,
30        atomic::{AtomicBool, Ordering},
31        mpsc,
32    },
33    thread,
34    time::{Duration, SystemTime},
35};
36
37use alloy_primitives::{Address, B256, keccak256};
38use alloy_signer::SignerSync;
39use alloy_signer_local::PrivateKeySigner;
40use alloy_sol_types::Eip712Domain;
41use nautilus_network::http::{HttpClient, Method};
42use serde::{Deserialize, Serialize};
43
44use super::consts::{
45    HYPERLIQUID_CHAIN_ID, NAUTILUS_BUILDER_FEE_ADDRESS, NAUTILUS_BUILDER_FEE_MAKER_TENTHS_BP,
46    NAUTILUS_BUILDER_FEE_TAKER_TENTHS_BP, exchange_url, info_url,
47};
48use crate::{
49    common::credential::EvmPrivateKey,
50    http::{
51        error::Result,
52        models::{HyperliquidExecBuilderFee, RESPONSE_STATUS_OK},
53    },
54};
55
56/// Builder fee approval rate (0.01% = 1 basis point).
57const BUILDER_CODES_APPROVAL_FEE_RATE: &str = "0.01%";
58
59/// Resolves the builder fee for an order based on symbol and post-only flag.
60///
61/// Returns `None` for spot orders (no builder fee). For perps, uses the
62/// maker rate when `post_only` is true, otherwise the taker rate.
63#[must_use]
64pub fn resolve_builder_fee(symbol: &str, post_only: bool) -> Option<HyperliquidExecBuilderFee> {
65    if symbol.ends_with("-SPOT") {
66        return None;
67    }
68
69    let fee_tenths_bp = if post_only {
70        NAUTILUS_BUILDER_FEE_MAKER_TENTHS_BP
71    } else {
72        NAUTILUS_BUILDER_FEE_TAKER_TENTHS_BP
73    };
74
75    Some(HyperliquidExecBuilderFee {
76        address: NAUTILUS_BUILDER_FEE_ADDRESS.to_string(),
77        fee_tenths_bp,
78    })
79}
80
81/// Resolves the builder fee for a batch of orders, using the lowest fee.
82///
83/// Returns `None` if any order is spot, the maker rate if any perp order
84/// is post-only, otherwise the taker rate.
85///
86/// Hyperliquid applies a single builder fee per action (not per order), so
87/// mixed post-only/taker batches use the minimum to avoid overcharging.
88/// Mixed spot/perp batches cannot occur since `OrderList` enforces a single
89/// instrument.
90#[must_use]
91pub fn resolve_builder_fee_batch(orders: &[(&str, bool)]) -> Option<HyperliquidExecBuilderFee> {
92    let mut min: Option<HyperliquidExecBuilderFee> = None;
93
94    for &(symbol, post_only) in orders {
95        let fee = resolve_builder_fee(symbol, post_only)?;
96        min = Some(match min {
97            Some(current) if current.fee_tenths_bp <= fee.fee_tenths_bp => current,
98            _ => fee,
99        });
100    }
101
102    min
103}
104
105/// Information about the Nautilus builder fee configuration.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct BuilderFeeInfo {
108    /// The builder address that receives fees.
109    pub address: String,
110    /// Taker fee rate for perpetuals in tenths of a basis point.
111    pub perp_taker_tenths_bp: u32,
112    /// Maker fee rate for perpetuals in tenths of a basis point.
113    pub perp_maker_tenths_bp: u32,
114    /// The approval rate required.
115    pub approval_rate: String,
116}
117
118impl Default for BuilderFeeInfo {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl BuilderFeeInfo {
125    /// Creates builder fee info from the hardcoded constants.
126    #[must_use]
127    pub fn new() -> Self {
128        Self {
129            address: NAUTILUS_BUILDER_FEE_ADDRESS.to_string(),
130            perp_taker_tenths_bp: NAUTILUS_BUILDER_FEE_TAKER_TENTHS_BP,
131            perp_maker_tenths_bp: NAUTILUS_BUILDER_FEE_MAKER_TENTHS_BP,
132            approval_rate: BUILDER_CODES_APPROVAL_FEE_RATE.to_string(),
133        }
134    }
135
136    /// Prints the builder fee configuration to stdout.
137    pub fn print(&self) {
138        let separator = "=".repeat(60);
139
140        println!("{separator}");
141        println!("NautilusTrader Hyperliquid Builder Fee Configuration");
142        println!("{separator}");
143        println!();
144        println!("Builder address: {}", self.address);
145        println!();
146        println!("Fee rates charged per fill (perpetuals only, no fee on spot):");
147        println!(
148            "  - Taker: {:.3}% ({} tenths of a basis point)",
149            self.perp_taker_tenths_bp as f64 / 1000.0,
150            self.perp_taker_tenths_bp,
151        );
152        println!(
153            "  - Maker: {:.3}% ({} tenths of a basis point)",
154            self.perp_maker_tenths_bp as f64 / 1000.0,
155            self.perp_maker_tenths_bp,
156        );
157        println!();
158        println!("These fees are charged in addition to Hyperliquid's standard fees.");
159        println!();
160        println!("This is at the low end of ecosystem norms.");
161        println!("Hyperliquid allows up to 0.1% (10 bps) for perps and 1% (100 bps) for spot.");
162        println!();
163        println!("Source: crates/adapters/hyperliquid/src/common/consts.rs");
164        println!("{separator}");
165    }
166}
167
168/// Result of a builder fee approval request.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct BuilderFeeApprovalResult {
171    /// Whether the approval was successful.
172    pub success: bool,
173    /// The status returned by Hyperliquid.
174    pub status: String,
175    /// Optional response message or error details.
176    pub message: Option<String>,
177    /// The wallet address that made the approval.
178    pub wallet_address: String,
179    /// The builder address that was approved.
180    pub builder_address: String,
181    /// Whether this was on testnet.
182    pub is_testnet: bool,
183}
184
185/// Approves the Nautilus builder fee for a wallet.
186///
187/// This signs an EIP-712 `ApproveBuilderFee` action and submits it to Hyperliquid.
188/// The approval allows NautilusTrader to include builder fees on orders for this wallet.
189///
190/// # Arguments
191///
192/// * `private_key` - The EVM private key (hex string with or without 0x prefix)
193/// * `is_testnet` - Whether to use testnet or mainnet
194///
195/// # Returns
196///
197/// The result of the approval request.
198///
199/// # Errors
200///
201/// Returns an error if the private key is invalid, signing fails, or the HTTP request fails.
202// Mutex/RwLock poisoning is not documented individually
203#[allow(clippy::missing_panics_doc)]
204pub async fn approve_builder_fee(
205    private_key: &str,
206    is_testnet: bool,
207) -> Result<BuilderFeeApprovalResult> {
208    let pk = EvmPrivateKey::new(private_key.to_string())?;
209    let wallet_address = derive_address(&pk)?;
210
211    let nonce = SystemTime::now()
212        .duration_since(SystemTime::UNIX_EPOCH)
213        .map_err(|e| crate::http::error::Error::transport(format!("Time error: {e}")))?
214        .as_millis() as u64;
215
216    let signature =
217        sign_approve_builder_fee(&pk, is_testnet, nonce, BUILDER_CODES_APPROVAL_FEE_RATE)?;
218
219    let action = serde_json::json!({
220        "type": "approveBuilderFee",
221        "hyperliquidChain": if is_testnet { "Testnet" } else { "Mainnet" },
222        "signatureChainId": "0x66eee",
223        "maxFeeRate": BUILDER_CODES_APPROVAL_FEE_RATE,
224        "builder": NAUTILUS_BUILDER_FEE_ADDRESS,
225        "nonce": nonce,
226    });
227
228    let payload = serde_json::json!({
229        "action": action,
230        "nonce": nonce,
231        "signature": signature,
232    });
233
234    let url = exchange_url(is_testnet);
235    let client =
236        HttpClient::new(HashMap::new(), vec![], vec![], None, None, None).map_err(|e| {
237            crate::http::error::Error::transport(format!("Failed to create client: {e}"))
238        })?;
239
240    let body_bytes = serde_json::to_vec(&payload)
241        .map_err(|e| crate::http::error::Error::transport(format!("Failed to serialize: {e}")))?;
242
243    let headers = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
244    let response = client
245        .request(
246            Method::POST,
247            url.to_string(),
248            None,
249            Some(headers),
250            Some(body_bytes),
251            None,
252            None,
253        )
254        .await
255        .map_err(|e| crate::http::error::Error::transport(format!("HTTP request failed: {e}")))?;
256
257    if !response.status.is_success() {
258        let body_str = String::from_utf8_lossy(&response.body);
259        return Err(crate::http::error::Error::transport(format!(
260            "HTTP {} from {url}: {}",
261            response.status.as_u16(),
262            if body_str.is_empty() {
263                "(empty response)"
264            } else {
265                &body_str
266            }
267        )));
268    }
269
270    let response_json: serde_json::Value = serde_json::from_slice(&response.body).map_err(|e| {
271        let body_str = String::from_utf8_lossy(&response.body);
272        crate::http::error::Error::transport(format!(
273            "Failed to parse JSON response from {url}: {e}. Body: {}",
274            if body_str.is_empty() {
275                "(empty)"
276            } else if body_str.len() > 200 {
277                &body_str[..200]
278            } else {
279                &body_str
280            }
281        ))
282    })?;
283
284    let status = response_json
285        .get("status")
286        .and_then(|v| v.as_str())
287        .unwrap_or("unknown")
288        .to_string();
289
290    let success = status == RESPONSE_STATUS_OK;
291    let message = response_json.get("response").map(|v: &serde_json::Value| {
292        if v.is_string() {
293            v.as_str().unwrap().to_string()
294        } else {
295            v.to_string()
296        }
297    });
298
299    Ok(BuilderFeeApprovalResult {
300        success,
301        status,
302        message,
303        wallet_address,
304        builder_address: NAUTILUS_BUILDER_FEE_ADDRESS.to_string(),
305        is_testnet,
306    })
307}
308
309/// Approves the Nautilus builder fee using environment variables.
310///
311/// Reads private key from environment:
312/// - Testnet: `HYPERLIQUID_TESTNET_PK`
313/// - Mainnet: `HYPERLIQUID_PK`
314///
315/// Set `HYPERLIQUID_TESTNET=true` to use testnet.
316///
317/// Prints progress and results to stdout.
318///
319/// # Arguments
320///
321/// * `non_interactive` - If true, skip confirmation prompt
322///
323/// # Returns
324///
325/// `true` if approval succeeded, `false` otherwise.
326pub async fn approve_from_env(non_interactive: bool) -> bool {
327    let is_testnet = env::var("HYPERLIQUID_TESTNET").is_ok_and(|v| v.to_lowercase() == "true");
328
329    let env_var = if is_testnet {
330        "HYPERLIQUID_TESTNET_PK"
331    } else {
332        "HYPERLIQUID_PK"
333    };
334
335    let private_key = match env::var(env_var) {
336        Ok(pk) => pk,
337        Err(_) => {
338            println!("Error: {env_var} environment variable not set");
339            return false;
340        }
341    };
342
343    let info = BuilderFeeInfo::new();
344    let network = if is_testnet { "testnet" } else { "mainnet" };
345
346    println!("Approving Nautilus builder fee on {network}");
347    println!("Builder address: {}", info.address);
348    println!(
349        "Approval rate: {} (covers perpetual taker and maker fills)",
350        info.approval_rate
351    );
352    println!("  - Taker: 1 bp (0.01%) on perpetual fills");
353    println!("  - Maker: 0.5 bp (0.005%) on perpetual post-only fills");
354    println!("  - Spot: no builder fee");
355    println!();
356    println!("This is at the low end of ecosystem norms.");
357    println!("Hyperliquid allows up to 0.1% (10 bps) for perps and 1% (100 bps) for spot.");
358    println!();
359
360    if !non_interactive && !wait_for_confirmation("Press Enter to approve or Ctrl+C to cancel... ")
361    {
362        return false;
363    }
364
365    println!("Approving builder fee...");
366
367    match approve_builder_fee(&private_key, is_testnet).await {
368        Ok(result) => {
369            println!();
370            println!("Wallet address: {}", result.wallet_address);
371            println!("Status: {}", result.status);
372            if let Some(msg) = &result.message {
373                println!("Response: {msg}");
374            }
375            println!();
376
377            if result.success {
378                println!("Builder fee approved successfully!");
379                println!("You can now trade on Hyperliquid via NautilusTrader.");
380                println!();
381                println!("To verify approval status at any time, run:");
382                println!(
383                    "  python nautilus_trader/adapters/hyperliquid/scripts/builder_fee_verify.py"
384                );
385            } else {
386                println!("Approval may have failed. Check the response above.");
387            }
388
389            result.success
390        }
391        Err(e) => {
392            println!("Error: {e}");
393            false
394        }
395    }
396}
397
398/// Revoke fee rate (0% effectively blocks the builder).
399const REVOKE_FEE_RATE: &str = "0%";
400
401/// Revokes the Nautilus builder fee approval for a wallet.
402///
403/// This signs an EIP-712 `ApproveBuilderFee` action with a 0% rate and submits
404/// it to Hyperliquid, effectively revoking the builder's permission.
405///
406/// # Arguments
407///
408/// * `private_key` - The EVM private key (hex string with or without 0x prefix)
409/// * `is_testnet` - Whether to use testnet or mainnet
410///
411/// # Returns
412///
413/// The result of the revoke request.
414///
415// Mutex/RwLock poisoning is not documented individually
416#[allow(clippy::missing_panics_doc)]
417pub async fn revoke_builder_fee(
418    private_key: &str,
419    is_testnet: bool,
420) -> Result<BuilderFeeApprovalResult> {
421    let pk = EvmPrivateKey::new(private_key.to_string())?;
422    let wallet_address = derive_address(&pk)?;
423
424    let nonce = SystemTime::now()
425        .duration_since(SystemTime::UNIX_EPOCH)
426        .map_err(|e| crate::http::error::Error::transport(format!("Time error: {e}")))?
427        .as_millis() as u64;
428
429    let signature = sign_approve_builder_fee(&pk, is_testnet, nonce, REVOKE_FEE_RATE)?;
430
431    let action = serde_json::json!({
432        "type": "approveBuilderFee",
433        "hyperliquidChain": if is_testnet { "Testnet" } else { "Mainnet" },
434        "signatureChainId": "0x66eee",
435        "maxFeeRate": REVOKE_FEE_RATE,
436        "builder": NAUTILUS_BUILDER_FEE_ADDRESS,
437        "nonce": nonce,
438    });
439
440    let payload = serde_json::json!({
441        "action": action,
442        "nonce": nonce,
443        "signature": signature,
444    });
445
446    let url = exchange_url(is_testnet);
447    let client =
448        HttpClient::new(HashMap::new(), vec![], vec![], None, None, None).map_err(|e| {
449            crate::http::error::Error::transport(format!("Failed to create client: {e}"))
450        })?;
451
452    let body_bytes = serde_json::to_vec(&payload)
453        .map_err(|e| crate::http::error::Error::transport(format!("Failed to serialize: {e}")))?;
454
455    let headers = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
456    let response = client
457        .request(
458            Method::POST,
459            url.to_string(),
460            None,
461            Some(headers),
462            Some(body_bytes),
463            None,
464            None,
465        )
466        .await
467        .map_err(|e| crate::http::error::Error::transport(format!("HTTP request failed: {e}")))?;
468
469    if !response.status.is_success() {
470        let body_str = String::from_utf8_lossy(&response.body);
471        return Err(crate::http::error::Error::transport(format!(
472            "HTTP {} from {url}: {}",
473            response.status.as_u16(),
474            if body_str.is_empty() {
475                "(empty response)"
476            } else {
477                &body_str
478            }
479        )));
480    }
481
482    let response_json: serde_json::Value = serde_json::from_slice(&response.body).map_err(|e| {
483        let body_str = String::from_utf8_lossy(&response.body);
484        crate::http::error::Error::transport(format!(
485            "Failed to parse JSON response from {url}: {e}. Body: {}",
486            if body_str.is_empty() {
487                "(empty)"
488            } else if body_str.len() > 200 {
489                &body_str[..200]
490            } else {
491                &body_str
492            }
493        ))
494    })?;
495
496    let status = response_json
497        .get("status")
498        .and_then(|v| v.as_str())
499        .unwrap_or("unknown")
500        .to_string();
501
502    let success = status == RESPONSE_STATUS_OK;
503    let message = response_json.get("response").map(|v: &serde_json::Value| {
504        if v.is_string() {
505            v.as_str().unwrap().to_string()
506        } else {
507            v.to_string()
508        }
509    });
510
511    Ok(BuilderFeeApprovalResult {
512        success,
513        status,
514        message,
515        wallet_address,
516        builder_address: NAUTILUS_BUILDER_FEE_ADDRESS.to_string(),
517        is_testnet,
518    })
519}
520
521/// Revokes the Nautilus builder fee using environment variables.
522///
523/// Reads private key from environment:
524/// - Testnet: `HYPERLIQUID_TESTNET_PK`
525/// - Mainnet: `HYPERLIQUID_PK`
526///
527/// Set `HYPERLIQUID_TESTNET=true` to use testnet.
528///
529/// Prints progress and results to stdout.
530///
531/// # Arguments
532///
533/// * `non_interactive` - If true, skip confirmation prompt
534///
535/// # Returns
536///
537/// `true` if revocation succeeded, `false` otherwise.
538pub async fn revoke_from_env(non_interactive: bool) -> bool {
539    let is_testnet = env::var("HYPERLIQUID_TESTNET").is_ok_and(|v| v.to_lowercase() == "true");
540
541    let env_var = if is_testnet {
542        "HYPERLIQUID_TESTNET_PK"
543    } else {
544        "HYPERLIQUID_PK"
545    };
546
547    let private_key = match env::var(env_var) {
548        Ok(pk) => pk,
549        Err(_) => {
550            println!("Error: {env_var} environment variable not set");
551            return false;
552        }
553    };
554
555    let network = if is_testnet { "testnet" } else { "mainnet" };
556
557    println!("Revoking Nautilus builder fee on {network}");
558    println!("Builder address: {NAUTILUS_BUILDER_FEE_ADDRESS}");
559    println!();
560    println!("WARNING: After revoking, you will not be able to trade on");
561    println!("Hyperliquid via NautilusTrader until you re-approve.");
562    println!();
563
564    if !non_interactive && !wait_for_confirmation("Press Enter to revoke or Ctrl+C to cancel... ") {
565        return false;
566    }
567
568    println!("Revoking builder fee...");
569
570    match revoke_builder_fee(&private_key, is_testnet).await {
571        Ok(result) => {
572            println!();
573            println!("Wallet address: {}", result.wallet_address);
574            println!("Status: {}", result.status);
575            if let Some(msg) = &result.message {
576                println!("Response: {msg}");
577            }
578            println!();
579
580            if result.success {
581                println!("Builder fee revoked successfully.");
582                println!("You will need to re-approve to trade via NautilusTrader.");
583            } else {
584                println!("Revocation may have failed. Check the response above.");
585            }
586
587            result.success
588        }
589        Err(e) => {
590            println!("Error: {e}");
591            false
592        }
593    }
594}
595
596/// Result of a builder fee verification query.
597#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct BuilderFeeVerifyResult {
599    /// The wallet address that was checked.
600    pub wallet_address: String,
601    /// The builder address that was checked.
602    pub builder_address: String,
603    /// The approved fee rate as a string (e.g., "1%"), or None if not approved.
604    pub approved_rate: Option<String>,
605    /// The required fee rate for NautilusTrader.
606    pub required_rate: String,
607    /// Whether the approval is sufficient.
608    pub is_approved: bool,
609    /// Whether this was on testnet.
610    pub is_testnet: bool,
611}
612
613/// Verifies builder fee approval status for a wallet.
614///
615/// Queries the Hyperliquid `maxBuilderFee` info endpoint to check if the
616/// wallet has approved the Nautilus builder fee at the required rate.
617///
618/// # Arguments
619///
620/// * `wallet_address` - The wallet address to check (hex string with 0x prefix)
621/// * `is_testnet` - Whether to use testnet or mainnet
622///
623/// # Returns
624///
625/// The verification result including approval status.
626pub async fn verify_builder_fee(
627    wallet_address: &str,
628    is_testnet: bool,
629) -> Result<BuilderFeeVerifyResult> {
630    let url = info_url(is_testnet);
631    let client =
632        HttpClient::new(HashMap::new(), vec![], vec![], None, None, None).map_err(|e| {
633            crate::http::error::Error::transport(format!("Failed to create client: {e}"))
634        })?;
635
636    let payload = serde_json::json!({
637        "type": "maxBuilderFee",
638        "user": wallet_address,
639        "builder": NAUTILUS_BUILDER_FEE_ADDRESS,
640    });
641
642    let body_bytes = serde_json::to_vec(&payload)
643        .map_err(|e| crate::http::error::Error::transport(format!("Failed to serialize: {e}")))?;
644
645    let headers = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
646    let response = client
647        .request(
648            Method::POST,
649            url.to_string(),
650            None,
651            Some(headers),
652            Some(body_bytes),
653            None,
654            None,
655        )
656        .await
657        .map_err(|e| crate::http::error::Error::transport(format!("HTTP request failed: {e}")))?;
658
659    if !response.status.is_success() {
660        let body_str = String::from_utf8_lossy(&response.body);
661        return Err(crate::http::error::Error::transport(format!(
662            "HTTP {} from {url}: {}",
663            response.status.as_u16(),
664            if body_str.is_empty() {
665                "(empty response)"
666            } else {
667                &body_str
668            }
669        )));
670    }
671
672    // API returns fee in tenths of basis points (e.g., 1000 = 1%) or "null"
673    let response_text = String::from_utf8_lossy(&response.body).trim().to_string();
674    let approved_tenths_bp: Option<u32> = if response_text == "null" {
675        None
676    } else {
677        response_text.parse().ok()
678    };
679
680    let approved_rate = approved_tenths_bp.map(|tenths| {
681        let bps = tenths as f64 / 10.0;
682        let percent = bps / 100.0;
683        format!("{percent}%")
684    });
685    let is_approved = approved_tenths_bp.is_some_and(|tenths| tenths >= 10);
686
687    Ok(BuilderFeeVerifyResult {
688        wallet_address: wallet_address.to_string(),
689        builder_address: NAUTILUS_BUILDER_FEE_ADDRESS.to_string(),
690        approved_rate,
691        required_rate: BUILDER_CODES_APPROVAL_FEE_RATE.to_string(),
692        is_approved,
693        is_testnet,
694    })
695}
696
697/// Verifies builder fee approval using an optional wallet address or environment variables.
698///
699/// If `wallet_address` is provided, uses it directly. Otherwise reads private key
700/// from environment to derive wallet address:
701/// - Testnet: `HYPERLIQUID_TESTNET_PK`
702/// - Mainnet: `HYPERLIQUID_PK`
703///
704/// Set `HYPERLIQUID_TESTNET=true` to use testnet.
705///
706/// Prints verification results to stdout.
707///
708/// # Returns
709///
710/// `true` if builder fee is approved at the required rate, `false` otherwise.
711pub async fn verify_from_env_or_address(wallet_address: Option<String>) -> bool {
712    let is_testnet = env::var("HYPERLIQUID_TESTNET").is_ok_and(|v| v.to_lowercase() == "true");
713
714    let wallet_address = match wallet_address {
715        Some(addr) => addr,
716        None => {
717            // Fall back to deriving from private key
718            let env_var = if is_testnet {
719                "HYPERLIQUID_TESTNET_PK"
720            } else {
721                "HYPERLIQUID_PK"
722            };
723
724            let private_key = match env::var(env_var) {
725                Ok(pk) => pk,
726                Err(_) => {
727                    println!("Error: No wallet address provided and {env_var} not set");
728                    return false;
729                }
730            };
731
732            let pk = match EvmPrivateKey::new(private_key) {
733                Ok(pk) => pk,
734                Err(e) => {
735                    println!("Error: Invalid private key: {e}");
736                    return false;
737                }
738            };
739
740            match derive_address(&pk) {
741                Ok(addr) => addr,
742                Err(e) => {
743                    println!("Error: Failed to derive address: {e}");
744                    return false;
745                }
746            }
747        }
748    };
749
750    let network = if is_testnet { "testnet" } else { "mainnet" };
751    let separator = "=".repeat(60);
752
753    println!("{separator}");
754    println!("Hyperliquid Builder Fee Verification");
755    println!("{separator}");
756    println!();
757    println!("Checking approval status on {network}...");
758    println!();
759
760    match verify_builder_fee(&wallet_address, is_testnet).await {
761        Ok(result) => {
762            println!("Wallet:   {}", result.wallet_address);
763            println!("Builder:  {}", result.builder_address);
764            println!("Network:  {network}");
765            println!(
766                "Approved: {}",
767                result.approved_rate.as_deref().unwrap_or("(none)")
768            );
769            println!();
770
771            if result.is_approved {
772                println!("Status: APPROVED");
773                println!();
774                println!("NautilusTrader builder fee rates (perpetuals only, no fee on spot):");
775                println!("  - Taker: 1 bp (0.01%) per fill");
776                println!("  - Maker: 0.5 bp (0.005%) per fill");
777                println!();
778                println!("This is at the low end of ecosystem norms.");
779                println!(
780                    "(Hyperliquid allows up to 0.1% (10 bps) for perps and 1% (100 bps) for spot)"
781                );
782                println!();
783                println!("You can trade on Hyperliquid via NautilusTrader.");
784            } else {
785                println!("Status: NOT APPROVED");
786                println!();
787                println!("Run the approval script:");
788                println!(
789                    "  python nautilus_trader/adapters/hyperliquid/scripts/builder_fee_approve.py"
790                );
791                println!();
792                println!("See: docs/integrations/hyperliquid.md#approving-builder-fees");
793            }
794
795            println!("{separator}");
796            result.is_approved
797        }
798        Err(e) => {
799            println!("Error: {e}");
800            false
801        }
802    }
803}
804
805fn sign_approve_builder_fee(
806    pk: &EvmPrivateKey,
807    is_testnet: bool,
808    nonce: u64,
809    fee_rate: &str,
810) -> Result<serde_json::Value> {
811    // EIP-712 domain separator hash (using alloy's Eip712Domain)
812    let domain_hash = compute_domain_hash();
813
814    // Struct type hash for HyperliquidTransaction:ApproveBuilderFee
815    let type_hash = keccak256(
816        b"HyperliquidTransaction:ApproveBuilderFee(string hyperliquidChain,string maxFeeRate,address builder,uint64 nonce)",
817    );
818
819    // Hash the message fields
820    let chain_str = if is_testnet { "Testnet" } else { "Mainnet" };
821    let chain_hash = keccak256(chain_str.as_bytes());
822    let fee_rate_hash = keccak256(fee_rate.as_bytes());
823
824    // Parse builder address
825    let builder_addr = Address::from_str(NAUTILUS_BUILDER_FEE_ADDRESS).map_err(|e| {
826        crate::http::error::Error::transport(format!("Invalid builder address: {e}"))
827    })?;
828
829    // Encode the struct hash
830    let mut struct_data = Vec::with_capacity(32 * 5);
831    struct_data.extend_from_slice(type_hash.as_slice());
832    struct_data.extend_from_slice(chain_hash.as_slice());
833    struct_data.extend_from_slice(fee_rate_hash.as_slice());
834
835    // Address is padded to 32 bytes (left-padded with zeros)
836    let mut addr_bytes = [0u8; 32];
837    addr_bytes[12..].copy_from_slice(builder_addr.as_slice());
838    struct_data.extend_from_slice(&addr_bytes);
839
840    // Nonce is uint64, padded to 32 bytes (left-padded with zeros)
841    let mut nonce_bytes = [0u8; 32];
842    nonce_bytes[24..].copy_from_slice(&nonce.to_be_bytes());
843    struct_data.extend_from_slice(&nonce_bytes);
844
845    let struct_hash = keccak256(&struct_data);
846
847    // Create final EIP-712 hash: \x19\x01 + domain_hash + struct_hash
848    let mut final_data = Vec::with_capacity(66);
849    final_data.extend_from_slice(b"\x19\x01");
850    final_data.extend_from_slice(&domain_hash);
851    final_data.extend_from_slice(struct_hash.as_slice());
852
853    let signing_hash = keccak256(&final_data);
854
855    // Sign the hash
856    let key_hex = pk.as_hex();
857    let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
858
859    let signer = PrivateKeySigner::from_str(key_hex).map_err(|e| {
860        crate::http::error::Error::transport(format!("Failed to create signer: {e}"))
861    })?;
862
863    let hash_b256 = B256::from(signing_hash);
864    let signature = signer
865        .sign_hash_sync(&hash_b256)
866        .map_err(|e| crate::http::error::Error::transport(format!("Failed to sign: {e}")))?;
867
868    // Format signature as {r, s, v} for Hyperliquid
869    let r = format!("0x{:064x}", signature.r());
870    let s = format!("0x{:064x}", signature.s());
871    let v = if signature.v() { 28u8 } else { 27u8 };
872
873    Ok(serde_json::json!({
874        "r": r,
875        "s": s,
876        "v": v,
877    }))
878}
879
880fn get_eip712_domain() -> Eip712Domain {
881    Eip712Domain {
882        name: Some("HyperliquidSignTransaction".into()),
883        version: Some("1".into()),
884        chain_id: Some(alloy_primitives::U256::from(HYPERLIQUID_CHAIN_ID)),
885        verifying_contract: Some(Address::ZERO),
886        salt: None,
887    }
888}
889
890fn compute_domain_hash() -> [u8; 32] {
891    *get_eip712_domain().hash_struct()
892}
893
894fn derive_address(pk: &EvmPrivateKey) -> Result<String> {
895    let key_hex = pk.as_hex();
896    let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
897
898    let signer = PrivateKeySigner::from_str(key_hex).map_err(|e| {
899        crate::http::error::Error::transport(format!("Failed to create signer: {e}"))
900    })?;
901
902    Ok(format!("{:#x}", signer.address()))
903}
904
905fn wait_for_confirmation(prompt: &str) -> bool {
906    let cancelled = Arc::new(AtomicBool::new(false));
907    let cancelled_clone = cancelled.clone();
908
909    if ctrlc::set_handler(move || {
910        cancelled_clone.store(true, Ordering::SeqCst);
911    })
912    .is_err()
913    {
914        // Handler already set, continue without it
915    }
916
917    print!("{prompt}");
918    io::stdout().flush().ok();
919
920    // Spawn thread to read stdin so we can check for ctrlc
921    let (tx, rx) = mpsc::channel();
922    thread::spawn(move || {
923        let mut input = String::new();
924        let result = io::stdin().read_line(&mut input);
925        let _ = tx.send(result);
926    });
927
928    // Wait for either input or ctrlc
929    loop {
930        if cancelled.load(Ordering::SeqCst) {
931            println!();
932            println!("Aborted.");
933            return false;
934        }
935
936        match rx.recv_timeout(Duration::from_millis(100)) {
937            Ok(Ok(0) | Err(_)) => {
938                println!();
939                println!("Aborted.");
940                return false;
941            }
942            Ok(Ok(_)) => {
943                println!();
944                return true;
945            }
946            Err(mpsc::RecvTimeoutError::Timeout) => continue,
947            Err(mpsc::RecvTimeoutError::Disconnected) => {
948                println!();
949                println!("Aborted.");
950                return false;
951            }
952        }
953    }
954}
955
956#[cfg(test)]
957mod tests {
958    use rstest::rstest;
959
960    use super::*;
961
962    #[rstest]
963    fn test_builder_fee_info() {
964        let info = BuilderFeeInfo::new();
965        assert_eq!(info.address, NAUTILUS_BUILDER_FEE_ADDRESS);
966        assert_eq!(info.perp_taker_tenths_bp, 10);
967        assert_eq!(info.perp_maker_tenths_bp, 5);
968        assert_eq!(info.approval_rate, "0.01%");
969    }
970
971    #[rstest]
972    fn test_derive_address() {
973        let pk = EvmPrivateKey::new(
974            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
975        )
976        .unwrap();
977        let addr = derive_address(&pk).unwrap();
978        assert!(addr.starts_with("0x"));
979        assert_eq!(addr.len(), 42);
980    }
981
982    #[rstest]
983    fn test_compute_domain_hash() {
984        let hash = compute_domain_hash();
985        assert_eq!(hash.len(), 32);
986    }
987
988    #[rstest]
989    fn test_sign_approve_builder_fee() {
990        let pk = EvmPrivateKey::new(
991            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
992        )
993        .unwrap();
994        let nonce = 1640995200000u64;
995
996        let signature =
997            sign_approve_builder_fee(&pk, false, nonce, BUILDER_CODES_APPROVAL_FEE_RATE).unwrap();
998
999        assert!(signature.get("r").is_some());
1000        assert!(signature.get("s").is_some());
1001        assert!(signature.get("v").is_some());
1002
1003        let r = signature["r"].as_str().unwrap();
1004        let s = signature["s"].as_str().unwrap();
1005
1006        assert!(r.starts_with("0x"));
1007        assert!(s.starts_with("0x"));
1008        assert_eq!(r.len(), 66); // 0x + 64 hex chars
1009        assert_eq!(s.len(), 66);
1010    }
1011}