asterdex-sdk 0.1.3

AsterDex Futures SDK v3 — Rust async client for REST and WebSocket APIs
Documentation
// US-002: Credentials struct
use alloy_primitives::Address;
use alloy_signer::SignerSync;
use alloy_signer_local::PrivateKeySigner;
use alloy_sol_types::Eip712Domain;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use crate::rest::error::AsterDexError;

/// SDK credentials — holds wallet addresses and EIP-712 signing key.
/// No Debug derive — private key must NEVER be logged.
pub struct Credentials {
    pub user: Address,
    pub signer: Address,
    signer_wallet: PrivateKeySigner,
    /// Last issued nonce (microseconds). Lock-free; guarantees strict monotonicity
    /// even under clock skew / NTP rollback.
    last_nonce: AtomicU64,
    /// Pre-computed "0x…" string for `user` — avoids one heap alloc per signed request.
    user_str: String,
    /// Pre-computed "0x…" string for `signer` — avoids one heap alloc per signed request.
    signer_str: String,
    /// Cached EIP-712 domain — built once at construction, reused on every signature.
    domain: Eip712Domain,
}

impl Credentials {
    /// Create from explicit string values
    pub fn new(
        user: &str,
        signer: &str,
        private_key_hex: &str,
        chain_id: u64,
    ) -> Result<Self, AsterDexError> {
        // Strip 0x prefix if present
        let key_hex = private_key_hex.trim_start_matches("0x");
        let wallet = key_hex.parse::<PrivateKeySigner>().map_err(|e| {
            AsterDexError::ConfigError {
                message: format!("invalid signer private key: {e}"),
            }
        })?;
        let user_addr = user.parse::<Address>().map_err(|e| {
            AsterDexError::ConfigError {
                message: format!("invalid user address: {e}"),
            }
        })?;
        let signer_addr = signer.parse::<Address>().map_err(|e| {
            AsterDexError::ConfigError {
                message: format!("invalid signer address: {e}"),
            }
        })?;

        // Pre-compute 0x-prefixed lowercase hex strings once.
        let user_str = format!("{:?}", user_addr);
        let signer_str = format!("{:?}", signer_addr);

        // Build EIP-712 domain once; reused on every sign_query call.
        let domain = crate::auth::eip712::get_domain(chain_id);

        Ok(Self {
            user: user_addr,
            signer: signer_addr,
            signer_wallet: wallet,
            last_nonce: AtomicU64::new(0),
            user_str,
            signer_str,
            domain,
        })
    }

    /// Create from environment variables
    pub fn from_env() -> Result<Self, AsterDexError> {
        let user = std::env::var("ASTERDEX_USER").map_err(|_| AsterDexError::ConfigError {
            message: "missing env var: ASTERDEX_USER".to_string(),
        })?;
        let signer =
            std::env::var("ASTERDEX_SIGNER_ADDRESS").map_err(|_| AsterDexError::ConfigError {
                message: "missing env var: ASTERDEX_SIGNER_ADDRESS".to_string(),
            })?;
        let key = std::env::var("ASTERDEX_SIGNER_PRIVATE_KEY").map_err(|_| {
            AsterDexError::ConfigError {
                message: "missing env var: ASTERDEX_SIGNER_PRIVATE_KEY".to_string(),
            }
        })?;
        let chain_id = match std::env::var("ASTERDEX_CHAIN_ID") {
            Ok(s) => s.parse::<u64>().map_err(|_| AsterDexError::ConfigError {
                message: format!("ASTERDEX_CHAIN_ID is not a valid integer: {s}"),
            })?,
            Err(_) => 1666, // mainnet default
        };
        Self::new(&user, &signer, &key, chain_id)
    }

    /// Generate a strictly monotonically increasing nonce.
    ///
    /// Format follows BR-001 / FORMULA-001: `unix_seconds * 1_000_000 + sub_second_index`.
    ///
    /// Implementation notes:
    /// - Lock-free via `AtomicU64::compare_exchange` → never blocks other async tasks.
    /// - If the wall clock jumps backwards (NTP rollback, VM pause/restore), we still
    ///   return `prev + 1` to preserve strict monotonicity.
    /// - If more than 1_000_000 nonces are requested within one wall-clock second,
    ///   nonces simply continue counting into the next "virtual second" — still
    ///   strictly monotonic, still uniquely identifying the request.
    /// - Never panics (vs. previous `.expect("time before epoch")`).
    pub fn new_nonce(&self) -> u64 {
        let base = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_micros() as u64)
            .unwrap_or(0);
        loop {
            let prev = self.last_nonce.load(Ordering::Relaxed);
            let next = base.max(prev.saturating_add(1));
            match self.last_nonce.compare_exchange(
                prev,
                next,
                Ordering::AcqRel,
                Ordering::Relaxed,
            ) {
                Ok(_) => return next,
                Err(_) => continue,
            }
        }
    }

    /// URL-encode params, add auth fields, sign with EIP-712.
    /// Returns the complete signed query string with `&signature=<hex>` appended.
    ///
    /// Optimisations vs. the previous implementation:
    /// - Address strings pre-computed at construction — no per-call format allocation.
    /// - Param string built in a single `String` with `push_str` — no intermediate `Vec<String>`.
    /// - EIP-712 domain cached at construction — no per-call struct rebuild.
    /// - Nonce / user / signer values are all ASCII-unreserved — URL-encoding skipped.
    pub fn sign_query(
        &self,
        params: &[(&str, &str)],
    ) -> Result<String, AsterDexError> {
        let nonce = self.new_nonce();
        let nonce_str = nonce.to_string();

        // Pre-allocate a single buffer for the entire query string.
        // Rough estimate: per-param ~64 bytes, plus 3 fixed auth fields + 130-char signature.
        let capacity = params.len() * 64 + 256;
        let mut param_string = String::with_capacity(capacity);

        // Caller-supplied params (URL-encoded, values may contain special chars).
        for (k, v) in params {
            if !param_string.is_empty() {
                param_string.push('&');
            }
            param_string.push_str(k);
            param_string.push('=');
            param_string.push_str(&urlencoding::encode(v));
        }

        // Auth fields — values contain only ASCII-unreserved characters (digits + hex),
        // so URL-encoding is a no-op and we skip it.
        if !param_string.is_empty() {
            param_string.push('&');
        }
        param_string.push_str("nonce=");
        param_string.push_str(&nonce_str);
        param_string.push_str("&user=");
        param_string.push_str(&self.user_str);
        param_string.push_str("&signer=");
        param_string.push_str(&self.signer_str);

        // EIP-712 sign using the cached domain.
        let hash = crate::auth::eip712::signing_hash(&param_string, &self.domain);
        let sig = self.signer_wallet.sign_hash_sync(&hash).map_err(|e| {
            AsterDexError::ConfigError {
                message: format!("signing failed: {e}"),
            }
        })?;

        // Append signature as hex (130 chars, NO 0x prefix per FORMULA-001).
        let sig_str = format!("{}", sig);
        let sig_hex = sig_str.strip_prefix("0x").unwrap_or(&sig_str);
        param_string.push_str("&signature=");
        param_string.push_str(sig_hex);

        Ok(param_string)
    }
}