nautilus_hyperliquid/common/
builder_fee.rs1use 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
58const ZERO_FEE_RATE: &str = "0%";
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct BuilderFeeApprovalResult {
65 pub success: bool,
67 pub status: String,
69 pub message: Option<String>,
71 pub wallet_address: String,
73 pub builder_address: String,
75 pub is_testnet: bool,
77}
78
79pub 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
116pub 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
149pub 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
165pub 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 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 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 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 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 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}