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