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