1use std::{
24 collections::HashMap,
25 io::{self, Write},
26 str::FromStr,
27 sync::{
28 Arc,
29 atomic::{AtomicBool, Ordering},
30 },
31 thread,
32 time::{Duration, SystemTime},
33};
34
35use alloy_primitives::{Address, B256, keccak256};
36use alloy_signer::SignerSync;
37use alloy_signer_local::PrivateKeySigner;
38use alloy_sol_types::Eip712Domain;
39use nautilus_network::http::{HttpClient, Method};
40use serde::{Deserialize, Serialize};
41
42use super::consts::{
43 NAUTILUS_BUILDER_FEE_ADDRESS, NAUTILUS_BUILDER_FEE_TENTHS_BP, exchange_url, info_url,
44};
45use crate::{common::credential::EvmPrivateKey, http::error::Result};
46
47const APPROVAL_FEE_RATE: &str = "0.01%";
49
50const HYPERLIQUID_CHAIN_ID: u64 = 421614;
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct BuilderFeeInfo {
56 pub address: String,
58 pub perp_rate_bps: u32,
60 pub spot_rate_bps: u32,
62 pub approval_rate: String,
64}
65
66impl Default for BuilderFeeInfo {
67 fn default() -> Self {
68 Self::new()
69 }
70}
71
72impl BuilderFeeInfo {
73 #[must_use]
75 pub fn new() -> Self {
76 Self {
77 address: NAUTILUS_BUILDER_FEE_ADDRESS.to_string(),
78 perp_rate_bps: NAUTILUS_BUILDER_FEE_TENTHS_BP / 10, spot_rate_bps: NAUTILUS_BUILDER_FEE_TENTHS_BP / 10,
80 approval_rate: APPROVAL_FEE_RATE.to_string(),
81 }
82 }
83
84 pub fn print(&self) {
86 let separator = "=".repeat(60);
87
88 println!("{separator}");
89 println!("NautilusTrader Hyperliquid Builder Fee Configuration");
90 println!("{separator}");
91 println!();
92 println!("Builder address: {}", self.address);
93 println!();
94 let bp_label = |n: u32| {
95 if n == 1 {
96 "basis point"
97 } else {
98 "basis points"
99 }
100 };
101 println!("Fee rates charged per fill:");
102 println!(
103 " - Perpetuals: {:.2}% ({} {})",
104 self.perp_rate_bps as f64 / 100.0,
105 self.perp_rate_bps,
106 bp_label(self.perp_rate_bps)
107 );
108 println!(
109 " - Spot sells: {:.2}% ({} {})",
110 self.spot_rate_bps as f64 / 100.0,
111 self.spot_rate_bps,
112 bp_label(self.spot_rate_bps)
113 );
114 println!();
115 println!("These fees are charged in addition to Hyperliquid's standard fees.");
116 println!();
117 println!("This is at the low end of ecosystem norms.");
118 println!("Hyperliquid allows up to 0.1% (10 bps) for perps and 1% (100 bps) for spot.");
119 println!();
120 println!("Source: crates/adapters/hyperliquid/src/common/consts.rs");
121 println!("{separator}");
122 }
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct BuilderFeeApprovalResult {
128 pub success: bool,
130 pub status: String,
132 pub message: Option<String>,
134 pub wallet_address: String,
136 pub builder_address: String,
138 pub is_testnet: bool,
140}
141
142pub async fn approve_builder_fee(
164 private_key: &str,
165 is_testnet: bool,
166) -> Result<BuilderFeeApprovalResult> {
167 let pk = EvmPrivateKey::new(private_key.to_string())?;
168 let wallet_address = derive_address(&pk)?;
169
170 let nonce = SystemTime::now()
171 .duration_since(SystemTime::UNIX_EPOCH)
172 .map_err(|e| crate::http::error::Error::transport(format!("Time error: {e}")))?
173 .as_millis() as u64;
174
175 let signature = sign_approve_builder_fee(&pk, is_testnet, nonce, APPROVAL_FEE_RATE)?;
176
177 let action = serde_json::json!({
178 "type": "approveBuilderFee",
179 "hyperliquidChain": if is_testnet { "Testnet" } else { "Mainnet" },
180 "signatureChainId": "0x66eee",
181 "maxFeeRate": APPROVAL_FEE_RATE,
182 "builder": NAUTILUS_BUILDER_FEE_ADDRESS,
183 "nonce": nonce,
184 });
185
186 let payload = serde_json::json!({
187 "action": action,
188 "nonce": nonce,
189 "signature": signature,
190 });
191
192 let url = exchange_url(is_testnet);
193 let client =
194 HttpClient::new(HashMap::new(), vec![], vec![], None, None, None).map_err(|e| {
195 crate::http::error::Error::transport(format!("Failed to create client: {e}"))
196 })?;
197
198 let body_bytes = serde_json::to_vec(&payload)
199 .map_err(|e| crate::http::error::Error::transport(format!("Failed to serialize: {e}")))?;
200
201 let headers = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
202 let response = client
203 .request(
204 Method::POST,
205 url.to_string(),
206 None,
207 Some(headers),
208 Some(body_bytes),
209 None,
210 None,
211 )
212 .await
213 .map_err(|e| crate::http::error::Error::transport(format!("HTTP request failed: {e}")))?;
214
215 if !response.status.is_success() {
216 let body_str = String::from_utf8_lossy(&response.body);
217 return Err(crate::http::error::Error::transport(format!(
218 "HTTP {} from {url}: {}",
219 response.status.as_u16(),
220 if body_str.is_empty() {
221 "(empty response)"
222 } else {
223 &body_str
224 }
225 )));
226 }
227
228 let response_json: serde_json::Value = serde_json::from_slice(&response.body).map_err(|e| {
229 let body_str = String::from_utf8_lossy(&response.body);
230 crate::http::error::Error::transport(format!(
231 "Failed to parse JSON response from {url}: {e}. Body: {}",
232 if body_str.is_empty() {
233 "(empty)"
234 } else if body_str.len() > 200 {
235 &body_str[..200]
236 } else {
237 &body_str
238 }
239 ))
240 })?;
241
242 let status = response_json
243 .get("status")
244 .and_then(|v| v.as_str())
245 .unwrap_or("unknown")
246 .to_string();
247
248 let success = status == "ok";
249 let message = response_json.get("response").map(|v: &serde_json::Value| {
250 if v.is_string() {
251 v.as_str().unwrap().to_string()
252 } else {
253 v.to_string()
254 }
255 });
256
257 Ok(BuilderFeeApprovalResult {
258 success,
259 status,
260 message,
261 wallet_address,
262 builder_address: NAUTILUS_BUILDER_FEE_ADDRESS.to_string(),
263 is_testnet,
264 })
265}
266
267pub async fn approve_from_env(non_interactive: bool) -> bool {
285 let is_testnet = std::env::var("HYPERLIQUID_TESTNET").is_ok_and(|v| v.to_lowercase() == "true");
286
287 let env_var = if is_testnet {
288 "HYPERLIQUID_TESTNET_PK"
289 } else {
290 "HYPERLIQUID_PK"
291 };
292
293 let private_key = match std::env::var(env_var) {
294 Ok(pk) => pk,
295 Err(_) => {
296 println!("Error: {env_var} environment variable not set");
297 return false;
298 }
299 };
300
301 let info = BuilderFeeInfo::new();
302 let network = if is_testnet { "testnet" } else { "mainnet" };
303
304 println!("Approving Nautilus builder fee on {network}");
305 println!("Builder address: {}", info.address);
306 println!(
307 "Approval rate: {} (1 basis point, covers perps and spot sells)",
308 info.approval_rate
309 );
310 println!();
311 println!("This is at the low end of ecosystem norms.");
312 println!("Hyperliquid allows up to 0.1% (10 bps) for perps and 1% (100 bps) for spot.");
313 println!();
314
315 if !non_interactive && !wait_for_confirmation("Press Enter to approve or Ctrl+C to cancel... ")
316 {
317 return false;
318 }
319
320 println!("Approving builder fee...");
321
322 match approve_builder_fee(&private_key, is_testnet).await {
323 Ok(result) => {
324 println!();
325 println!("Wallet address: {}", result.wallet_address);
326 println!("Status: {}", result.status);
327 if let Some(msg) = &result.message {
328 println!("Response: {msg}");
329 }
330 println!();
331
332 if result.success {
333 println!("Builder fee approved successfully!");
334 println!("You can now trade on Hyperliquid via NautilusTrader.");
335 println!();
336 println!("To verify approval status at any time, run:");
337 println!(
338 " python nautilus_trader/adapters/hyperliquid/scripts/builder_fee_verify.py"
339 );
340 } else {
341 println!("Approval may have failed. Check the response above.");
342 }
343
344 result.success
345 }
346 Err(e) => {
347 println!("Error: {e}");
348 false
349 }
350 }
351}
352
353const REVOKE_FEE_RATE: &str = "0%";
355
356pub async fn revoke_builder_fee(
374 private_key: &str,
375 is_testnet: bool,
376) -> Result<BuilderFeeApprovalResult> {
377 let pk = EvmPrivateKey::new(private_key.to_string())?;
378 let wallet_address = derive_address(&pk)?;
379
380 let nonce = SystemTime::now()
381 .duration_since(SystemTime::UNIX_EPOCH)
382 .map_err(|e| crate::http::error::Error::transport(format!("Time error: {e}")))?
383 .as_millis() as u64;
384
385 let signature = sign_approve_builder_fee(&pk, is_testnet, nonce, REVOKE_FEE_RATE)?;
386
387 let action = serde_json::json!({
388 "type": "approveBuilderFee",
389 "hyperliquidChain": if is_testnet { "Testnet" } else { "Mainnet" },
390 "signatureChainId": "0x66eee",
391 "maxFeeRate": REVOKE_FEE_RATE,
392 "builder": NAUTILUS_BUILDER_FEE_ADDRESS,
393 "nonce": nonce,
394 });
395
396 let payload = serde_json::json!({
397 "action": action,
398 "nonce": nonce,
399 "signature": signature,
400 });
401
402 let url = exchange_url(is_testnet);
403 let client =
404 HttpClient::new(HashMap::new(), vec![], vec![], None, None, None).map_err(|e| {
405 crate::http::error::Error::transport(format!("Failed to create client: {e}"))
406 })?;
407
408 let body_bytes = serde_json::to_vec(&payload)
409 .map_err(|e| crate::http::error::Error::transport(format!("Failed to serialize: {e}")))?;
410
411 let headers = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
412 let response = client
413 .request(
414 Method::POST,
415 url.to_string(),
416 None,
417 Some(headers),
418 Some(body_bytes),
419 None,
420 None,
421 )
422 .await
423 .map_err(|e| crate::http::error::Error::transport(format!("HTTP request failed: {e}")))?;
424
425 if !response.status.is_success() {
426 let body_str = String::from_utf8_lossy(&response.body);
427 return Err(crate::http::error::Error::transport(format!(
428 "HTTP {} from {url}: {}",
429 response.status.as_u16(),
430 if body_str.is_empty() {
431 "(empty response)"
432 } else {
433 &body_str
434 }
435 )));
436 }
437
438 let response_json: serde_json::Value = serde_json::from_slice(&response.body).map_err(|e| {
439 let body_str = String::from_utf8_lossy(&response.body);
440 crate::http::error::Error::transport(format!(
441 "Failed to parse JSON response from {url}: {e}. Body: {}",
442 if body_str.is_empty() {
443 "(empty)"
444 } else if body_str.len() > 200 {
445 &body_str[..200]
446 } else {
447 &body_str
448 }
449 ))
450 })?;
451
452 let status = response_json
453 .get("status")
454 .and_then(|v| v.as_str())
455 .unwrap_or("unknown")
456 .to_string();
457
458 let success = status == "ok";
459 let message = response_json.get("response").map(|v: &serde_json::Value| {
460 if v.is_string() {
461 v.as_str().unwrap().to_string()
462 } else {
463 v.to_string()
464 }
465 });
466
467 Ok(BuilderFeeApprovalResult {
468 success,
469 status,
470 message,
471 wallet_address,
472 builder_address: NAUTILUS_BUILDER_FEE_ADDRESS.to_string(),
473 is_testnet,
474 })
475}
476
477pub async fn revoke_from_env(non_interactive: bool) -> bool {
495 let is_testnet = std::env::var("HYPERLIQUID_TESTNET").is_ok_and(|v| v.to_lowercase() == "true");
496
497 let env_var = if is_testnet {
498 "HYPERLIQUID_TESTNET_PK"
499 } else {
500 "HYPERLIQUID_PK"
501 };
502
503 let private_key = match std::env::var(env_var) {
504 Ok(pk) => pk,
505 Err(_) => {
506 println!("Error: {env_var} environment variable not set");
507 return false;
508 }
509 };
510
511 let network = if is_testnet { "testnet" } else { "mainnet" };
512
513 println!("Revoking Nautilus builder fee on {network}");
514 println!("Builder address: {NAUTILUS_BUILDER_FEE_ADDRESS}");
515 println!();
516 println!("WARNING: After revoking, you will not be able to trade on");
517 println!("Hyperliquid via NautilusTrader until you re-approve.");
518 println!();
519
520 if !non_interactive && !wait_for_confirmation("Press Enter to revoke or Ctrl+C to cancel... ") {
521 return false;
522 }
523
524 println!("Revoking builder fee...");
525
526 match revoke_builder_fee(&private_key, is_testnet).await {
527 Ok(result) => {
528 println!();
529 println!("Wallet address: {}", result.wallet_address);
530 println!("Status: {}", result.status);
531 if let Some(msg) = &result.message {
532 println!("Response: {msg}");
533 }
534 println!();
535
536 if result.success {
537 println!("Builder fee revoked successfully.");
538 println!("You will need to re-approve to trade via NautilusTrader.");
539 } else {
540 println!("Revocation may have failed. Check the response above.");
541 }
542
543 result.success
544 }
545 Err(e) => {
546 println!("Error: {e}");
547 false
548 }
549 }
550}
551
552#[derive(Debug, Clone, Serialize, Deserialize)]
554pub struct BuilderFeeVerifyResult {
555 pub wallet_address: String,
557 pub builder_address: String,
559 pub approved_rate: Option<String>,
561 pub required_rate: String,
563 pub is_approved: bool,
565 pub is_testnet: bool,
567}
568
569pub async fn verify_builder_fee(
583 wallet_address: &str,
584 is_testnet: bool,
585) -> Result<BuilderFeeVerifyResult> {
586 let url = info_url(is_testnet);
587 let client =
588 HttpClient::new(HashMap::new(), vec![], vec![], None, None, None).map_err(|e| {
589 crate::http::error::Error::transport(format!("Failed to create client: {e}"))
590 })?;
591
592 let payload = serde_json::json!({
593 "type": "maxBuilderFee",
594 "user": wallet_address,
595 "builder": NAUTILUS_BUILDER_FEE_ADDRESS,
596 });
597
598 let body_bytes = serde_json::to_vec(&payload)
599 .map_err(|e| crate::http::error::Error::transport(format!("Failed to serialize: {e}")))?;
600
601 let headers = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
602 let response = client
603 .request(
604 Method::POST,
605 url.to_string(),
606 None,
607 Some(headers),
608 Some(body_bytes),
609 None,
610 None,
611 )
612 .await
613 .map_err(|e| crate::http::error::Error::transport(format!("HTTP request failed: {e}")))?;
614
615 if !response.status.is_success() {
616 let body_str = String::from_utf8_lossy(&response.body);
617 return Err(crate::http::error::Error::transport(format!(
618 "HTTP {} from {url}: {}",
619 response.status.as_u16(),
620 if body_str.is_empty() {
621 "(empty response)"
622 } else {
623 &body_str
624 }
625 )));
626 }
627
628 let response_text = String::from_utf8_lossy(&response.body).trim().to_string();
630 let approved_tenths_bp: Option<u32> = if response_text == "null" {
631 None
632 } else {
633 response_text.parse().ok()
634 };
635
636 let approved_rate = approved_tenths_bp.map(|tenths| {
637 let bps = tenths as f64 / 10.0;
638 let percent = bps / 100.0;
639 format!("{percent}%")
640 });
641 let is_approved = approved_tenths_bp.is_some_and(|tenths| tenths >= 10);
642
643 Ok(BuilderFeeVerifyResult {
644 wallet_address: wallet_address.to_string(),
645 builder_address: NAUTILUS_BUILDER_FEE_ADDRESS.to_string(),
646 approved_rate,
647 required_rate: APPROVAL_FEE_RATE.to_string(),
648 is_approved,
649 is_testnet,
650 })
651}
652
653pub async fn verify_from_env_or_address(wallet_address: Option<String>) -> bool {
668 let is_testnet = std::env::var("HYPERLIQUID_TESTNET").is_ok_and(|v| v.to_lowercase() == "true");
669
670 let wallet_address = match wallet_address {
671 Some(addr) => addr,
672 None => {
673 let env_var = if is_testnet {
675 "HYPERLIQUID_TESTNET_PK"
676 } else {
677 "HYPERLIQUID_PK"
678 };
679
680 let private_key = match std::env::var(env_var) {
681 Ok(pk) => pk,
682 Err(_) => {
683 println!("Error: No wallet address provided and {env_var} not set");
684 return false;
685 }
686 };
687
688 let pk = match EvmPrivateKey::new(private_key) {
689 Ok(pk) => pk,
690 Err(e) => {
691 println!("Error: Invalid private key: {e}");
692 return false;
693 }
694 };
695
696 match derive_address(&pk) {
697 Ok(addr) => addr,
698 Err(e) => {
699 println!("Error: Failed to derive address: {e}");
700 return false;
701 }
702 }
703 }
704 };
705
706 let network = if is_testnet { "testnet" } else { "mainnet" };
707 let separator = "=".repeat(60);
708
709 println!("{separator}");
710 println!("Hyperliquid Builder Fee Verification");
711 println!("{separator}");
712 println!();
713 println!("Checking approval status on {network}...");
714 println!();
715
716 match verify_builder_fee(&wallet_address, is_testnet).await {
717 Ok(result) => {
718 println!("Wallet: {}", result.wallet_address);
719 println!("Builder: {}", result.builder_address);
720 println!("Network: {network}");
721 println!(
722 "Approved: {}",
723 result.approved_rate.as_deref().unwrap_or("(none)")
724 );
725 println!();
726
727 if result.is_approved {
728 println!("Status: APPROVED");
729 println!();
730 println!(
731 "NautilusTrader charges 0.01% (1 basis point) per fill (perps and spot sells)."
732 );
733 println!("This is at the low end of ecosystem norms.");
734 println!(
735 "(Hyperliquid allows up to 0.1% (10 bps) for perps and 1% (100 bps) for spot)"
736 );
737 println!();
738 println!("You can trade on Hyperliquid via NautilusTrader.");
739 } else {
740 println!("Status: NOT APPROVED");
741 println!();
742 println!("Run the approval script:");
743 println!(
744 " python nautilus_trader/adapters/hyperliquid/scripts/builder_fee_approve.py"
745 );
746 println!();
747 println!("See: docs/integrations/hyperliquid.md#approving-builder-fees");
748 }
749
750 println!("{separator}");
751 result.is_approved
752 }
753 Err(e) => {
754 println!("Error: {e}");
755 false
756 }
757 }
758}
759
760fn sign_approve_builder_fee(
761 pk: &EvmPrivateKey,
762 is_testnet: bool,
763 nonce: u64,
764 fee_rate: &str,
765) -> Result<serde_json::Value> {
766 let domain_hash = compute_domain_hash();
768
769 let type_hash = keccak256(
771 b"HyperliquidTransaction:ApproveBuilderFee(string hyperliquidChain,string maxFeeRate,address builder,uint64 nonce)",
772 );
773
774 let chain_str = if is_testnet { "Testnet" } else { "Mainnet" };
776 let chain_hash = keccak256(chain_str.as_bytes());
777 let fee_rate_hash = keccak256(fee_rate.as_bytes());
778
779 let builder_addr = Address::from_str(NAUTILUS_BUILDER_FEE_ADDRESS).map_err(|e| {
781 crate::http::error::Error::transport(format!("Invalid builder address: {e}"))
782 })?;
783
784 let mut struct_data = Vec::with_capacity(32 * 5);
786 struct_data.extend_from_slice(type_hash.as_slice());
787 struct_data.extend_from_slice(chain_hash.as_slice());
788 struct_data.extend_from_slice(fee_rate_hash.as_slice());
789
790 let mut addr_bytes = [0u8; 32];
792 addr_bytes[12..].copy_from_slice(builder_addr.as_slice());
793 struct_data.extend_from_slice(&addr_bytes);
794
795 let mut nonce_bytes = [0u8; 32];
797 nonce_bytes[24..].copy_from_slice(&nonce.to_be_bytes());
798 struct_data.extend_from_slice(&nonce_bytes);
799
800 let struct_hash = keccak256(&struct_data);
801
802 let mut final_data = Vec::with_capacity(66);
804 final_data.extend_from_slice(b"\x19\x01");
805 final_data.extend_from_slice(&domain_hash);
806 final_data.extend_from_slice(struct_hash.as_slice());
807
808 let signing_hash = keccak256(&final_data);
809
810 let key_hex = pk.as_hex();
812 let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
813
814 let signer = PrivateKeySigner::from_str(key_hex).map_err(|e| {
815 crate::http::error::Error::transport(format!("Failed to create signer: {e}"))
816 })?;
817
818 let hash_b256 = B256::from(signing_hash);
819 let signature = signer
820 .sign_hash_sync(&hash_b256)
821 .map_err(|e| crate::http::error::Error::transport(format!("Failed to sign: {e}")))?;
822
823 let r = format!("0x{:064x}", signature.r());
825 let s = format!("0x{:064x}", signature.s());
826 let v = if signature.v() { 28u8 } else { 27u8 };
827
828 Ok(serde_json::json!({
829 "r": r,
830 "s": s,
831 "v": v,
832 }))
833}
834
835fn get_eip712_domain() -> Eip712Domain {
836 Eip712Domain {
837 name: Some("HyperliquidSignTransaction".into()),
838 version: Some("1".into()),
839 chain_id: Some(alloy_primitives::U256::from(HYPERLIQUID_CHAIN_ID)),
840 verifying_contract: Some(Address::ZERO),
841 salt: None,
842 }
843}
844
845fn compute_domain_hash() -> [u8; 32] {
846 *get_eip712_domain().hash_struct()
847}
848
849fn derive_address(pk: &EvmPrivateKey) -> Result<String> {
850 let key_hex = pk.as_hex();
851 let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
852
853 let signer = PrivateKeySigner::from_str(key_hex).map_err(|e| {
854 crate::http::error::Error::transport(format!("Failed to create signer: {e}"))
855 })?;
856
857 Ok(format!("{:#x}", signer.address()))
858}
859
860fn wait_for_confirmation(prompt: &str) -> bool {
861 let cancelled = Arc::new(AtomicBool::new(false));
862 let cancelled_clone = cancelled.clone();
863
864 if ctrlc::set_handler(move || {
865 cancelled_clone.store(true, Ordering::SeqCst);
866 })
867 .is_err()
868 {
869 }
871
872 print!("{prompt}");
873 io::stdout().flush().ok();
874
875 let (tx, rx) = std::sync::mpsc::channel();
877 thread::spawn(move || {
878 let mut input = String::new();
879 let result = io::stdin().read_line(&mut input);
880 let _ = tx.send(result);
881 });
882
883 loop {
885 if cancelled.load(Ordering::SeqCst) {
886 println!();
887 println!("Aborted.");
888 return false;
889 }
890
891 match rx.recv_timeout(Duration::from_millis(100)) {
892 Ok(Ok(0) | Err(_)) => {
893 println!();
894 println!("Aborted.");
895 return false;
896 }
897 Ok(Ok(_)) => {
898 println!();
899 return true;
900 }
901 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
902 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
903 println!();
904 println!("Aborted.");
905 return false;
906 }
907 }
908 }
909}
910
911#[cfg(test)]
912mod tests {
913 use rstest::rstest;
914
915 use super::*;
916
917 #[rstest]
918 fn test_builder_fee_info() {
919 let info = BuilderFeeInfo::new();
920 assert_eq!(info.address, NAUTILUS_BUILDER_FEE_ADDRESS);
921 assert_eq!(info.perp_rate_bps, 1); assert_eq!(info.spot_rate_bps, 1); assert_eq!(info.approval_rate, "0.01%");
924 }
925
926 #[rstest]
927 fn test_derive_address() {
928 let pk = EvmPrivateKey::new(
929 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
930 )
931 .unwrap();
932 let addr = derive_address(&pk).unwrap();
933 assert!(addr.starts_with("0x"));
934 assert_eq!(addr.len(), 42);
935 }
936
937 #[rstest]
938 fn test_compute_domain_hash() {
939 let hash = compute_domain_hash();
940 assert_eq!(hash.len(), 32);
941 }
942
943 #[rstest]
944 fn test_sign_approve_builder_fee() {
945 let pk = EvmPrivateKey::new(
946 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
947 )
948 .unwrap();
949 let nonce = 1640995200000u64;
950
951 let signature = sign_approve_builder_fee(&pk, false, nonce, APPROVAL_FEE_RATE).unwrap();
952
953 assert!(signature.get("r").is_some());
954 assert!(signature.get("s").is_some());
955 assert!(signature.get("v").is_some());
956
957 let r = signature["r"].as_str().unwrap();
958 let s = signature["s"].as_str().unwrap();
959
960 assert!(r.starts_with("0x"));
961 assert!(s.starts_with("0x"));
962 assert_eq!(r.len(), 66); assert_eq!(s.len(), 66);
964 }
965}