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 {
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
119pub 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
152pub 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
168pub 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 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 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 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 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 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}