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::{
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
61const ZERO_FEE_RATE: &str = "0%";
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct BuilderFeeApprovalResult {
68 pub success: bool,
70 pub status: String,
72 pub message: Option<String>,
74 pub wallet_address: String,
76 pub builder_address: String,
78 pub is_testnet: bool,
80}
81
82pub 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
124pub 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
162pub 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
178pub 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 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 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 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 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 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}