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::builder()
261 .timeout_secs(60)
262 .build()
263 .map_err(|e| Error::transport(format!("Failed to create client: {e}")))?;
264
265 let body_bytes = serde_json::to_vec(&payload)
266 .map_err(|e| Error::transport(format!("Failed to serialize: {e}")))?;
267
268 let headers = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
269 let response = client
270 .request(
271 Method::POST,
272 url.to_string(),
273 None,
274 Some(headers),
275 Some(body_bytes),
276 None,
277 None,
278 )
279 .await
280 .map_err(|e| Error::transport(format!("HTTP request failed: {e}")))?;
281
282 if !response.status.is_success() {
283 let body_str = String::from_utf8_lossy(&response.body);
284 return Err(Error::transport(format!(
285 "HTTP {} from {url}: {}",
286 response.status.as_u16(),
287 if body_str.is_empty() {
288 "(empty response)"
289 } else {
290 &body_str
291 }
292 )));
293 }
294
295 let response_json: serde_json::Value = serde_json::from_slice(&response.body).map_err(|e| {
296 let body_str = String::from_utf8_lossy(&response.body);
297 let preview: String = body_str.chars().take(200).collect();
298 Error::transport(format!(
299 "Failed to parse JSON response from {url}: {e}. Body: {}",
300 if preview.is_empty() {
301 "(empty)"
302 } else {
303 &preview
304 }
305 ))
306 })?;
307
308 let status = response_json
309 .get("status")
310 .and_then(|v| v.as_str())
311 .unwrap_or("unknown")
312 .to_string();
313
314 let success = status == RESPONSE_STATUS_OK;
315 let message = response_json.get("response").map(|v| match v.as_str() {
316 Some(s) => s.to_string(),
317 None => v.to_string(),
318 });
319
320 Ok(BuilderFeeApprovalResult {
321 success,
322 status,
323 message,
324 wallet_address,
325 builder_address: NAUTILUS_BUILDER_ADDRESS.to_string(),
326 is_testnet,
327 })
328}
329
330fn build_approval_action(is_testnet: bool, nonce: u64) -> serde_json::Value {
331 serde_json::json!({
332 "type": "approveBuilderFee",
333 "hyperliquidChain": if is_testnet { "Testnet" } else { "Mainnet" },
334 "signatureChainId": format!("{HYPERLIQUID_CHAIN_ID:#x}"),
335 "maxFeeRate": ZERO_FEE_RATE,
336 "builder": NAUTILUS_BUILDER_ADDRESS,
337 "nonce": nonce,
338 })
339}
340
341fn sign_approve_builder_fee(
342 pk: &EvmPrivateKey,
343 is_testnet: bool,
344 nonce: u64,
345 fee_rate: &str,
346) -> Result<HyperliquidSignature> {
347 let signing_hash = approval_signing_hash(is_testnet, nonce, fee_rate)?;
348
349 let key_hex = pk.as_hex();
350 let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
351
352 let signer = PrivateKeySigner::from_str(key_hex)
353 .map_err(|e| Error::auth(format!("Failed to create signer: {e}")))?;
354
355 let signature = signer
356 .sign_hash_sync(&signing_hash)
357 .map_err(|e| Error::auth(format!("Failed to sign: {e}")))?;
358
359 let r = format!("0x{:064x}", signature.r());
360 let s = format!("0x{:064x}", signature.s());
361 let v = if signature.v() { 28u64 } else { 27u64 };
362
363 Ok(HyperliquidSignature::new(r, s, v))
364}
365
366fn approval_signing_hash(is_testnet: bool, nonce: u64, fee_rate: &str) -> Result<B256> {
367 let domain = eip712_domain! {
368 name: "HyperliquidSignTransaction",
369 version: "1",
370 chain_id: HYPERLIQUID_CHAIN_ID,
371 verifying_contract: Address::ZERO,
372 };
373 let domain_hash = domain.hash_struct();
374
375 let type_hash = keccak256(
378 b"HyperliquidTransaction:ApproveBuilderFee(string hyperliquidChain,string maxFeeRate,address builder,uint64 nonce)",
379 );
380
381 let chain_str = if is_testnet { "Testnet" } else { "Mainnet" };
382 let chain_hash = keccak256(chain_str.as_bytes());
383 let fee_rate_hash = keccak256(fee_rate.as_bytes());
384
385 let builder_addr = Address::from_str(NAUTILUS_BUILDER_ADDRESS)
386 .map_err(|e| Error::transport(format!("Invalid builder address: {e}")))?;
387
388 let mut struct_data = Vec::with_capacity(32 * 5);
389 struct_data.extend_from_slice(type_hash.as_slice());
390 struct_data.extend_from_slice(chain_hash.as_slice());
391 struct_data.extend_from_slice(fee_rate_hash.as_slice());
392
393 let mut addr_bytes = [0u8; 32];
395 addr_bytes[12..].copy_from_slice(builder_addr.as_slice());
396 struct_data.extend_from_slice(&addr_bytes);
397
398 let mut nonce_bytes = [0u8; 32];
400 nonce_bytes[24..].copy_from_slice(&nonce.to_be_bytes());
401 struct_data.extend_from_slice(&nonce_bytes);
402
403 let struct_hash = keccak256(&struct_data);
404
405 let mut final_data = Vec::with_capacity(66);
407 final_data.extend_from_slice(b"\x19\x01");
408 final_data.extend_from_slice(domain_hash.as_slice());
409 final_data.extend_from_slice(struct_hash.as_slice());
410
411 Ok(keccak256(&final_data))
412}
413
414fn derive_address(pk: &EvmPrivateKey) -> Result<String> {
415 let key_hex = pk.as_hex();
416 let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
417
418 let signer = PrivateKeySigner::from_str(key_hex)
419 .map_err(|e| Error::auth(format!("Failed to create signer: {e}")))?;
420
421 Ok(format!("{:#x}", signer.address()))
422}
423
424async fn wait_for_confirmation(prompt: &str) -> bool {
425 print!("{prompt}");
426 io::stdout().flush().ok();
427
428 let stdin_read = tokio::task::spawn_blocking(|| {
429 let mut input = String::new();
430 io::stdin().read_line(&mut input)
431 });
432
433 tokio::select! {
434 result = stdin_read => match result {
435 Ok(Ok(0) | Err(_)) | Err(_) => {
436 println!();
437 println!("Aborted.");
438 false
439 }
440 Ok(Ok(_)) => {
441 println!();
442 true
443 }
444 },
445 _ = tokio::signal::ctrl_c() => {
446 println!();
447 println!("Aborted.");
448 false
449 }
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use rstest::rstest;
456
457 use super::*;
458
459 const TEST_PK: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
461 const TEST_ADDRESS: &str = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266";
462
463 #[rstest]
464 fn test_derive_address_known_key() {
465 let pk = EvmPrivateKey::new(TEST_PK).unwrap();
466
467 let address = derive_address(&pk).unwrap();
468
469 assert_eq!(address, TEST_ADDRESS);
470 }
471
472 #[rstest]
473 fn test_build_approval_action_payload() {
474 let action = build_approval_action(false, 1_700_000_000_000);
475
476 assert_eq!(action["type"], "approveBuilderFee");
477 assert_eq!(action["hyperliquidChain"], "Mainnet");
478 assert_eq!(action["signatureChainId"], "0x66eee");
479 assert_eq!(action["maxFeeRate"], "0%");
480 assert_eq!(action["builder"], NAUTILUS_BUILDER_ADDRESS);
481 assert_eq!(action["nonce"], 1_700_000_000_000_u64);
482 }
483
484 #[rstest]
485 fn test_build_approval_action_testnet_chain() {
486 let action = build_approval_action(true, 1);
487
488 assert_eq!(action["hyperliquidChain"], "Testnet");
489 }
490
491 #[rstest]
492 fn test_sign_approve_builder_fee_recovers_signer() {
493 let pk = EvmPrivateKey::new(TEST_PK).unwrap();
494 let nonce = 1_700_000_000_000;
495
496 let signature = sign_approve_builder_fee(&pk, false, nonce, ZERO_FEE_RATE).unwrap();
497
498 let signing_hash = approval_signing_hash(false, nonce, ZERO_FEE_RATE).unwrap();
499 let signer = PrivateKeySigner::from_str(TEST_PK.strip_prefix("0x").unwrap()).unwrap();
500 let direct = signer.sign_hash_sync(&signing_hash).unwrap();
501 let recovered = direct.recover_address_from_prehash(&signing_hash).unwrap();
502
503 assert_eq!(signature.r, format!("0x{:064x}", direct.r()));
504 assert_eq!(signature.s, format!("0x{:064x}", direct.s()));
505 assert_eq!(signature.v, if direct.v() { 28 } else { 27 });
506 assert_eq!(format!("{recovered:#x}"), TEST_ADDRESS);
507 }
508
509 #[rstest]
510 fn test_approval_signing_hash_varies_with_inputs() {
511 let base = approval_signing_hash(false, 1, ZERO_FEE_RATE).unwrap();
512
513 assert_ne!(base, approval_signing_hash(true, 1, ZERO_FEE_RATE).unwrap());
514 assert_ne!(
515 base,
516 approval_signing_hash(false, 2, ZERO_FEE_RATE).unwrap()
517 );
518 assert_ne!(base, approval_signing_hash(false, 1, "0.001%").unwrap());
519 }
520}