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 revocation for Hyperliquid.
17//!
18//! Hyperliquid rejects orders that carry a builder address from a wallet that has
19//! never approved a builder fee, even when the order fee is zero. This module signs
20//! the one-time EIP-712 `ApproveBuilderFee` action at a 0% max fee rate, enabling
21//! the zero-fee Nautilus builder attribution without ever charging a fee.
22//!
23//! Revocation signs the same action at the same 0% rate: it caps any previously
24//! approved builder fee at zero (for example, an approval from a version that
25//! charged builder fees).
26//!
27//! The action must be signed by the master wallet's private key; agent (API)
28//! wallets cannot sign `ApproveBuilderFee`.
29
30use std::{
31    collections::HashMap,
32    env,
33    io::{self, Write},
34    str::FromStr,
35    time::SystemTime,
36};
37
38use alloy::{
39    signers::{SignerSync, local::PrivateKeySigner},
40    sol_types::eip712_domain,
41};
42use alloy_primitives::{Address, B256, keccak256};
43use nautilus_network::http::{HttpClient, Method};
44use serde::{Deserialize, Serialize};
45
46use super::{
47    consts::{HYPERLIQUID_CHAIN_ID, NAUTILUS_BUILDER_ADDRESS, exchange_url},
48    enums::HyperliquidEnvironment,
49};
50use crate::{
51    common::credential::EvmPrivateKey,
52    http::{
53        error::{Error, Result},
54        models::{HyperliquidSignature, RESPONSE_STATUS_OK},
55    },
56};
57
58// Zero max fee rate: approval enables attribution without ever permitting a
59// charge, revocation caps any previously approved rate at zero.
60const ZERO_FEE_RATE: &str = "0%";
61
62/// Result of a builder fee approval request.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct BuilderFeeApprovalResult {
65    /// Whether the request was successful.
66    pub success: bool,
67    /// The status returned by Hyperliquid.
68    pub status: String,
69    /// Optional response message or error details.
70    pub message: Option<String>,
71    /// The wallet address that made the request.
72    pub wallet_address: String,
73    /// The builder address.
74    pub builder_address: String,
75    /// Whether this was on testnet.
76    pub is_testnet: bool,
77}
78
79/// Approves the Nautilus builder fee using environment variables.
80///
81/// Reads private key from environment:
82/// - Testnet: `HYPERLIQUID_TESTNET_PK`
83/// - Mainnet: `HYPERLIQUID_PK`
84///
85/// Set `HYPERLIQUID_TESTNET=true` to use testnet.
86pub async fn approve_from_env(non_interactive: bool) -> bool {
87    let is_testnet = testnet_from_env();
88    let Some(private_key) = private_key_from_env(is_testnet) else {
89        return false;
90    };
91    let network = if is_testnet { "testnet" } else { "mainnet" };
92
93    println!("Approving Nautilus builder attribution on {network}");
94    println!("Builder address: {NAUTILUS_BUILDER_ADDRESS}");
95    println!("Max fee rate: {ZERO_FEE_RATE} (attribution only, no fees are charged)");
96    println!();
97    println!("This signs a one-time ApproveBuilderFee action so orders can carry the");
98    println!("Nautilus builder address. The action must be signed by the master wallet.");
99    println!();
100
101    if !non_interactive
102        && !wait_for_confirmation("Press Enter to approve or Ctrl+C to cancel... ").await
103    {
104        return false;
105    }
106
107    println!("Approving builder fee...");
108
109    report_result(
110        approve_builder_fee(&private_key, is_testnet).await,
111        "Builder fee approved successfully.",
112        "Approval may have failed. Check the response above.",
113    )
114}
115
116/// Revokes the Nautilus builder fee using environment variables.
117///
118/// Reads private key from environment:
119/// - Testnet: `HYPERLIQUID_TESTNET_PK`
120/// - Mainnet: `HYPERLIQUID_PK`
121///
122/// Set `HYPERLIQUID_TESTNET=true` to use testnet.
123pub async fn revoke_from_env(non_interactive: bool) -> bool {
124    let is_testnet = testnet_from_env();
125    let Some(private_key) = private_key_from_env(is_testnet) else {
126        return false;
127    };
128    let network = if is_testnet { "testnet" } else { "mainnet" };
129
130    println!("Revoking Nautilus builder fee on {network}");
131    println!("Builder address: {NAUTILUS_BUILDER_ADDRESS}");
132    println!();
133
134    if !non_interactive
135        && !wait_for_confirmation("Press Enter to revoke or Ctrl+C to cancel... ").await
136    {
137        return false;
138    }
139
140    println!("Revoking builder fee...");
141
142    report_result(
143        revoke_builder_fee(&private_key, is_testnet).await,
144        "Builder fee revoked successfully.",
145        "Revocation may have failed. Check the response above.",
146    )
147}
148
149/// Approves the Nautilus builder fee for a wallet.
150///
151/// This signs an EIP-712 `ApproveBuilderFee` action with a 0% max fee rate and
152/// submits it to Hyperliquid, permitting the zero-fee builder attribution.
153///
154/// # Errors
155///
156/// Returns an error if the private key is invalid, signing fails, or the
157/// request cannot be submitted.
158pub async fn approve_builder_fee(
159    private_key: &str,
160    is_testnet: bool,
161) -> Result<BuilderFeeApprovalResult> {
162    submit_builder_fee_update(private_key, is_testnet).await
163}
164
165/// Revokes the Nautilus builder fee approval for a wallet.
166///
167/// This signs an EIP-712 `ApproveBuilderFee` action with a 0% max fee rate and
168/// submits it to Hyperliquid, capping any previously approved builder fee at
169/// zero so no fee can be charged.
170///
171/// # Errors
172///
173/// Returns an error if the private key is invalid, signing fails, or the
174/// request cannot be submitted.
175pub async fn revoke_builder_fee(
176    private_key: &str,
177    is_testnet: bool,
178) -> Result<BuilderFeeApprovalResult> {
179    submit_builder_fee_update(private_key, is_testnet).await
180}
181
182fn testnet_from_env() -> bool {
183    env::var("HYPERLIQUID_TESTNET").is_ok_and(|v| v.to_lowercase() == "true" || v == "1")
184}
185
186fn private_key_from_env(is_testnet: bool) -> Option<String> {
187    let env_var = if is_testnet {
188        "HYPERLIQUID_TESTNET_PK"
189    } else {
190        "HYPERLIQUID_PK"
191    };
192
193    match env::var(env_var) {
194        Ok(pk) => Some(pk),
195        Err(_) => {
196            println!("Error: {env_var} environment variable not set");
197            None
198        }
199    }
200}
201
202fn report_result(
203    result: Result<BuilderFeeApprovalResult>,
204    success_msg: &str,
205    failure_msg: &str,
206) -> bool {
207    match result {
208        Ok(result) => {
209            println!();
210            println!("Wallet address: {}", result.wallet_address);
211            println!("Status: {}", result.status);
212            if let Some(msg) = &result.message {
213                println!("Response: {msg}");
214            }
215            println!();
216
217            if result.success {
218                println!("{success_msg}");
219            } else {
220                println!("{failure_msg}");
221            }
222
223            result.success
224        }
225        Err(e) => {
226            println!("Error: {e}");
227            false
228        }
229    }
230}
231
232async fn submit_builder_fee_update(
233    private_key: &str,
234    is_testnet: bool,
235) -> Result<BuilderFeeApprovalResult> {
236    let pk = EvmPrivateKey::new(private_key)?;
237    let wallet_address = derive_address(&pk)?;
238
239    let nonce = SystemTime::now()
240        .duration_since(SystemTime::UNIX_EPOCH)
241        .map_err(|e| Error::transport(format!("Time error: {e}")))?
242        .as_millis() as u64;
243
244    let signature = sign_approve_builder_fee(&pk, is_testnet, nonce, ZERO_FEE_RATE)?;
245    let action = build_approval_action(is_testnet, nonce);
246
247    let payload = serde_json::json!({
248        "action": action,
249        "nonce": nonce,
250        "signature": signature,
251    });
252
253    let environment = if is_testnet {
254        HyperliquidEnvironment::Testnet
255    } else {
256        HyperliquidEnvironment::Mainnet
257    };
258    let url = exchange_url(environment);
259
260    let client = HttpClient::new(HashMap::new(), vec![], vec![], None, Some(60), None)
261        .map_err(|e| Error::transport(format!("Failed to create client: {e}")))?;
262
263    let body_bytes = serde_json::to_vec(&payload)
264        .map_err(|e| Error::transport(format!("Failed to serialize: {e}")))?;
265
266    let headers = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
267    let response = client
268        .request(
269            Method::POST,
270            url.to_string(),
271            None,
272            Some(headers),
273            Some(body_bytes),
274            None,
275            None,
276        )
277        .await
278        .map_err(|e| Error::transport(format!("HTTP request failed: {e}")))?;
279
280    if !response.status.is_success() {
281        let body_str = String::from_utf8_lossy(&response.body);
282        return Err(Error::transport(format!(
283            "HTTP {} from {url}: {}",
284            response.status.as_u16(),
285            if body_str.is_empty() {
286                "(empty response)"
287            } else {
288                &body_str
289            }
290        )));
291    }
292
293    let response_json: serde_json::Value = serde_json::from_slice(&response.body).map_err(|e| {
294        let body_str = String::from_utf8_lossy(&response.body);
295        let preview: String = body_str.chars().take(200).collect();
296        Error::transport(format!(
297            "Failed to parse JSON response from {url}: {e}. Body: {}",
298            if preview.is_empty() {
299                "(empty)"
300            } else {
301                &preview
302            }
303        ))
304    })?;
305
306    let status = response_json
307        .get("status")
308        .and_then(|v| v.as_str())
309        .unwrap_or("unknown")
310        .to_string();
311
312    let success = status == RESPONSE_STATUS_OK;
313    let message = response_json.get("response").map(|v| match v.as_str() {
314        Some(s) => s.to_string(),
315        None => v.to_string(),
316    });
317
318    Ok(BuilderFeeApprovalResult {
319        success,
320        status,
321        message,
322        wallet_address,
323        builder_address: NAUTILUS_BUILDER_ADDRESS.to_string(),
324        is_testnet,
325    })
326}
327
328fn build_approval_action(is_testnet: bool, nonce: u64) -> serde_json::Value {
329    serde_json::json!({
330        "type": "approveBuilderFee",
331        "hyperliquidChain": if is_testnet { "Testnet" } else { "Mainnet" },
332        "signatureChainId": format!("{HYPERLIQUID_CHAIN_ID:#x}"),
333        "maxFeeRate": ZERO_FEE_RATE,
334        "builder": NAUTILUS_BUILDER_ADDRESS,
335        "nonce": nonce,
336    })
337}
338
339fn sign_approve_builder_fee(
340    pk: &EvmPrivateKey,
341    is_testnet: bool,
342    nonce: u64,
343    fee_rate: &str,
344) -> Result<HyperliquidSignature> {
345    let signing_hash = approval_signing_hash(is_testnet, nonce, fee_rate)?;
346
347    let key_hex = pk.as_hex();
348    let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
349
350    let signer = PrivateKeySigner::from_str(key_hex)
351        .map_err(|e| Error::auth(format!("Failed to create signer: {e}")))?;
352
353    let signature = signer
354        .sign_hash_sync(&signing_hash)
355        .map_err(|e| Error::auth(format!("Failed to sign: {e}")))?;
356
357    let r = format!("0x{:064x}", signature.r());
358    let s = format!("0x{:064x}", signature.s());
359    let v = if signature.v() { 28u64 } else { 27u64 };
360
361    Ok(HyperliquidSignature::new(r, s, v))
362}
363
364fn approval_signing_hash(is_testnet: bool, nonce: u64, fee_rate: &str) -> Result<B256> {
365    let domain = eip712_domain! {
366        name: "HyperliquidSignTransaction",
367        version: "1",
368        chain_id: HYPERLIQUID_CHAIN_ID,
369        verifying_contract: Address::ZERO,
370    };
371    let domain_hash = domain.hash_struct();
372
373    // Struct type hash for HyperliquidTransaction:ApproveBuilderFee, the colon in
374    // the type name rules out the alloy sol! macro, so the encoding is hand-rolled.
375    let type_hash = keccak256(
376        b"HyperliquidTransaction:ApproveBuilderFee(string hyperliquidChain,string maxFeeRate,address builder,uint64 nonce)",
377    );
378
379    let chain_str = if is_testnet { "Testnet" } else { "Mainnet" };
380    let chain_hash = keccak256(chain_str.as_bytes());
381    let fee_rate_hash = keccak256(fee_rate.as_bytes());
382
383    let builder_addr = Address::from_str(NAUTILUS_BUILDER_ADDRESS)
384        .map_err(|e| Error::transport(format!("Invalid builder address: {e}")))?;
385
386    let mut struct_data = Vec::with_capacity(32 * 5);
387    struct_data.extend_from_slice(type_hash.as_slice());
388    struct_data.extend_from_slice(chain_hash.as_slice());
389    struct_data.extend_from_slice(fee_rate_hash.as_slice());
390
391    // Address left-padded to 32 bytes
392    let mut addr_bytes = [0u8; 32];
393    addr_bytes[12..].copy_from_slice(builder_addr.as_slice());
394    struct_data.extend_from_slice(&addr_bytes);
395
396    // Nonce as uint64, left-padded to 32 bytes
397    let mut nonce_bytes = [0u8; 32];
398    nonce_bytes[24..].copy_from_slice(&nonce.to_be_bytes());
399    struct_data.extend_from_slice(&nonce_bytes);
400
401    let struct_hash = keccak256(&struct_data);
402
403    // EIP-712 hash: \x19\x01 + domain_hash + struct_hash
404    let mut final_data = Vec::with_capacity(66);
405    final_data.extend_from_slice(b"\x19\x01");
406    final_data.extend_from_slice(domain_hash.as_slice());
407    final_data.extend_from_slice(struct_hash.as_slice());
408
409    Ok(keccak256(&final_data))
410}
411
412fn derive_address(pk: &EvmPrivateKey) -> Result<String> {
413    let key_hex = pk.as_hex();
414    let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
415
416    let signer = PrivateKeySigner::from_str(key_hex)
417        .map_err(|e| Error::auth(format!("Failed to create signer: {e}")))?;
418
419    Ok(format!("{:#x}", signer.address()))
420}
421
422async fn wait_for_confirmation(prompt: &str) -> bool {
423    print!("{prompt}");
424    io::stdout().flush().ok();
425
426    let stdin_read = tokio::task::spawn_blocking(|| {
427        let mut input = String::new();
428        io::stdin().read_line(&mut input)
429    });
430
431    tokio::select! {
432        result = stdin_read => match result {
433            Ok(Ok(0) | Err(_)) | Err(_) => {
434                println!();
435                println!("Aborted.");
436                false
437            }
438            Ok(Ok(_)) => {
439                println!();
440                true
441            }
442        },
443        _ = tokio::signal::ctrl_c() => {
444            println!();
445            println!("Aborted.");
446            false
447        }
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use rstest::rstest;
454
455    use super::*;
456
457    // Well-known development key (hardhat/anvil account 0)
458    const TEST_PK: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
459    const TEST_ADDRESS: &str = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266";
460
461    #[rstest]
462    fn test_derive_address_known_key() {
463        let pk = EvmPrivateKey::new(TEST_PK).unwrap();
464
465        let address = derive_address(&pk).unwrap();
466
467        assert_eq!(address, TEST_ADDRESS);
468    }
469
470    #[rstest]
471    fn test_build_approval_action_payload() {
472        let action = build_approval_action(false, 1_700_000_000_000);
473
474        assert_eq!(action["type"], "approveBuilderFee");
475        assert_eq!(action["hyperliquidChain"], "Mainnet");
476        assert_eq!(action["signatureChainId"], "0x66eee");
477        assert_eq!(action["maxFeeRate"], "0%");
478        assert_eq!(action["builder"], NAUTILUS_BUILDER_ADDRESS);
479        assert_eq!(action["nonce"], 1_700_000_000_000_u64);
480    }
481
482    #[rstest]
483    fn test_build_approval_action_testnet_chain() {
484        let action = build_approval_action(true, 1);
485
486        assert_eq!(action["hyperliquidChain"], "Testnet");
487    }
488
489    #[rstest]
490    fn test_sign_approve_builder_fee_recovers_signer() {
491        let pk = EvmPrivateKey::new(TEST_PK).unwrap();
492        let nonce = 1_700_000_000_000;
493
494        let signature = sign_approve_builder_fee(&pk, false, nonce, ZERO_FEE_RATE).unwrap();
495
496        let signing_hash = approval_signing_hash(false, nonce, ZERO_FEE_RATE).unwrap();
497        let signer = PrivateKeySigner::from_str(TEST_PK.strip_prefix("0x").unwrap()).unwrap();
498        let direct = signer.sign_hash_sync(&signing_hash).unwrap();
499        let recovered = direct.recover_address_from_prehash(&signing_hash).unwrap();
500
501        assert_eq!(signature.r, format!("0x{:064x}", direct.r()));
502        assert_eq!(signature.s, format!("0x{:064x}", direct.s()));
503        assert_eq!(signature.v, if direct.v() { 28 } else { 27 });
504        assert_eq!(format!("{recovered:#x}"), TEST_ADDRESS);
505    }
506
507    #[rstest]
508    fn test_approval_signing_hash_varies_with_inputs() {
509        let base = approval_signing_hash(false, 1, ZERO_FEE_RATE).unwrap();
510
511        assert_ne!(base, approval_signing_hash(true, 1, ZERO_FEE_RATE).unwrap());
512        assert_ne!(
513            base,
514            approval_signing_hash(false, 2, ZERO_FEE_RATE).unwrap()
515        );
516        assert_ne!(base, approval_signing_hash(false, 1, "0.001%").unwrap());
517    }
518}