Skip to main content

asterdex_sdk/auth/
credentials.rs

1// US-002: Credentials struct
2use alloy_primitives::Address;
3use alloy_signer::SignerSync;
4use alloy_signer_local::PrivateKeySigner;
5use alloy_sol_types::Eip712Domain;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use crate::rest::error::AsterDexError;
10
11/// SDK credentials — holds wallet addresses and EIP-712 signing key.
12/// No Debug derive — private key must NEVER be logged.
13pub struct Credentials {
14    pub user: Address,
15    pub signer: Address,
16    signer_wallet: PrivateKeySigner,
17    /// Last issued nonce (microseconds). Lock-free; guarantees strict monotonicity
18    /// even under clock skew / NTP rollback.
19    last_nonce: AtomicU64,
20    /// Pre-computed "0x…" string for `user` — avoids one heap alloc per signed request.
21    user_str: String,
22    /// Pre-computed "0x…" string for `signer` — avoids one heap alloc per signed request.
23    signer_str: String,
24    /// Cached EIP-712 domain — built once at construction, reused on every signature.
25    domain: Eip712Domain,
26}
27
28impl Credentials {
29    /// Create from explicit string values
30    pub fn new(
31        user: &str,
32        signer: &str,
33        private_key_hex: &str,
34        chain_id: u64,
35    ) -> Result<Self, AsterDexError> {
36        // Strip 0x prefix if present
37        let key_hex = private_key_hex.trim_start_matches("0x");
38        let wallet = key_hex.parse::<PrivateKeySigner>().map_err(|e| {
39            AsterDexError::ConfigError {
40                message: format!("invalid signer private key: {e}"),
41            }
42        })?;
43        let user_addr = user.parse::<Address>().map_err(|e| {
44            AsterDexError::ConfigError {
45                message: format!("invalid user address: {e}"),
46            }
47        })?;
48        let signer_addr = signer.parse::<Address>().map_err(|e| {
49            AsterDexError::ConfigError {
50                message: format!("invalid signer address: {e}"),
51            }
52        })?;
53
54        // Pre-compute 0x-prefixed lowercase hex strings once.
55        let user_str = format!("{:?}", user_addr);
56        let signer_str = format!("{:?}", signer_addr);
57
58        // Build EIP-712 domain once; reused on every sign_query call.
59        let domain = crate::auth::eip712::get_domain(chain_id);
60
61        Ok(Self {
62            user: user_addr,
63            signer: signer_addr,
64            signer_wallet: wallet,
65            last_nonce: AtomicU64::new(0),
66            user_str,
67            signer_str,
68            domain,
69        })
70    }
71
72    /// Create from environment variables
73    pub fn from_env() -> Result<Self, AsterDexError> {
74        let user = std::env::var("ASTERDEX_USER").map_err(|_| AsterDexError::ConfigError {
75            message: "missing env var: ASTERDEX_USER".to_string(),
76        })?;
77        let signer =
78            std::env::var("ASTERDEX_SIGNER_ADDRESS").map_err(|_| AsterDexError::ConfigError {
79                message: "missing env var: ASTERDEX_SIGNER_ADDRESS".to_string(),
80            })?;
81        let key = std::env::var("ASTERDEX_SIGNER_PRIVATE_KEY").map_err(|_| {
82            AsterDexError::ConfigError {
83                message: "missing env var: ASTERDEX_SIGNER_PRIVATE_KEY".to_string(),
84            }
85        })?;
86        let chain_id = match std::env::var("ASTERDEX_CHAIN_ID") {
87            Ok(s) => s.parse::<u64>().map_err(|_| AsterDexError::ConfigError {
88                message: format!("ASTERDEX_CHAIN_ID is not a valid integer: {s}"),
89            })?,
90            Err(_) => 1666, // mainnet default
91        };
92        Self::new(&user, &signer, &key, chain_id)
93    }
94
95    /// Generate a strictly monotonically increasing nonce.
96    ///
97    /// Format follows BR-001 / FORMULA-001: `unix_seconds * 1_000_000 + sub_second_index`.
98    ///
99    /// Implementation notes:
100    /// - Lock-free via `AtomicU64::compare_exchange` → never blocks other async tasks.
101    /// - If the wall clock jumps backwards (NTP rollback, VM pause/restore), we still
102    ///   return `prev + 1` to preserve strict monotonicity.
103    /// - If more than 1_000_000 nonces are requested within one wall-clock second,
104    ///   nonces simply continue counting into the next "virtual second" — still
105    ///   strictly monotonic, still uniquely identifying the request.
106    /// - Never panics (vs. previous `.expect("time before epoch")`).
107    pub fn new_nonce(&self) -> u64 {
108        let base = SystemTime::now()
109            .duration_since(UNIX_EPOCH)
110            .map(|d| d.as_micros() as u64)
111            .unwrap_or(0);
112        loop {
113            let prev = self.last_nonce.load(Ordering::Relaxed);
114            let next = base.max(prev.saturating_add(1));
115            match self.last_nonce.compare_exchange(
116                prev,
117                next,
118                Ordering::AcqRel,
119                Ordering::Relaxed,
120            ) {
121                Ok(_) => return next,
122                Err(_) => continue,
123            }
124        }
125    }
126
127    /// URL-encode params, add auth fields, sign with EIP-712.
128    /// Returns the complete signed query string with `&signature=<hex>` appended.
129    ///
130    /// Optimisations vs. the previous implementation:
131    /// - Address strings pre-computed at construction — no per-call format allocation.
132    /// - Param string built in a single `String` with `push_str` — no intermediate `Vec<String>`.
133    /// - EIP-712 domain cached at construction — no per-call struct rebuild.
134    /// - Nonce / user / signer values are all ASCII-unreserved — URL-encoding skipped.
135    pub fn sign_query(
136        &self,
137        params: &[(&str, &str)],
138    ) -> Result<String, AsterDexError> {
139        let nonce = self.new_nonce();
140        let nonce_str = nonce.to_string();
141
142        // Pre-allocate a single buffer for the entire query string.
143        // Rough estimate: per-param ~64 bytes, plus 3 fixed auth fields + 130-char signature.
144        let capacity = params.len() * 64 + 256;
145        let mut param_string = String::with_capacity(capacity);
146
147        // Caller-supplied params (URL-encoded, values may contain special chars).
148        for (k, v) in params {
149            if !param_string.is_empty() {
150                param_string.push('&');
151            }
152            param_string.push_str(k);
153            param_string.push('=');
154            param_string.push_str(&urlencoding::encode(v));
155        }
156
157        // Auth fields — values contain only ASCII-unreserved characters (digits + hex),
158        // so URL-encoding is a no-op and we skip it.
159        if !param_string.is_empty() {
160            param_string.push('&');
161        }
162        param_string.push_str("nonce=");
163        param_string.push_str(&nonce_str);
164        param_string.push_str("&user=");
165        param_string.push_str(&self.user_str);
166        param_string.push_str("&signer=");
167        param_string.push_str(&self.signer_str);
168
169        // EIP-712 sign using the cached domain.
170        let hash = crate::auth::eip712::signing_hash(&param_string, &self.domain);
171        let sig = self.signer_wallet.sign_hash_sync(&hash).map_err(|e| {
172            AsterDexError::ConfigError {
173                message: format!("signing failed: {e}"),
174            }
175        })?;
176
177        // Append signature as hex (130 chars, NO 0x prefix per FORMULA-001).
178        let sig_str = format!("{}", sig);
179        let sig_hex = sig_str.strip_prefix("0x").unwrap_or(&sig_str);
180        param_string.push_str("&signature=");
181        param_string.push_str(sig_hex);
182
183        Ok(param_string)
184    }
185}