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