ndaxrs 0.1.0

Rust client library for the NDAX cryptocurrency exchange API
Documentation
// Copyright (C) 2026 ndaxrs Art Morozov
// SPDX-License-Identifier: GPL-3.0-only

//! Authentication types and signature generation for the NDAX API.

use std::time::{SystemTime, UNIX_EPOCH};

use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;

use crate::config::NdaxCredentials;

type HmacSha256 = Hmac<Sha256>;

/// Generates an HMAC-SHA256 signature for NDAX authentication.
///
/// The message format is `nonce + user_id + api_key` (concatenated, no
/// separators). The signature is returned as lowercase hex.
///
/// # Arguments
///
/// * `nonce` - A unique timestamp-based string
/// * `user_id` - The NDAX user ID
/// * `api_key` - The NDAX API key
/// * `api_secret` - The NDAX API secret used as HMAC key
///
/// # Returns
///
/// The HMAC-SHA256 signature as a lowercase hex string.
pub fn generate_signature(
  nonce: &str,
  user_id: &str,
  api_key: &str,
  api_secret: &str,
) -> String {
  let message = format!("{}{}{}", nonce, user_id, api_key);
  let mut mac = HmacSha256::new_from_slice(api_secret.as_bytes())
    .expect("HMAC can take key of any size");
  mac.update(message.as_bytes());
  let result = mac.finalize().into_bytes();
  hex::encode(result)
}

/// Request payload for the `AuthenticateUser` API endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthenticateUserRequest {
  /// The API key.
  #[serde(rename = "APIKey")]
  pub api_key:   String,
  /// The HMAC-SHA256 signature.
  #[serde(rename = "Signature")]
  pub signature: String,
  /// The user ID.
  #[serde(rename = "UserId")]
  pub user_id:   String,
  /// The nonce (timestamp in milliseconds).
  #[serde(rename = "Nonce")]
  pub nonce:     String,
}

/// Response payload for the `AuthenticateUser` API endpoint.
///
/// NDAX may return fields in either camelCase or PascalCase,
/// so we accept both via aliases.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthenticateUserResponse {
  /// Whether authentication was successful.
  #[serde(alias = "Authenticated")]
  pub authenticated: bool,
  /// Whether the account is locked.
  #[serde(alias = "Locked")]
  pub locked:        bool,
  /// Whether two-factor authentication is required.
  #[serde(rename = "requires2FA", alias = "Requires2FA")]
  pub requires_2fa:  bool,
  /// The type of 2FA (if required).
  #[serde(rename = "twoFAType", alias = "TwoFAType")]
  pub two_fa_type:   Option<String>,
  /// The 2FA token (if applicable).
  #[serde(rename = "twoFAToken", alias = "TwoFAToken")]
  pub two_fa_token:  Option<String>,
  /// User information (present on successful auth).
  #[serde(alias = "User")]
  pub user:          Option<UserInfo>,
}

/// User information returned in authentication response.
///
/// NDAX may return fields in either camelCase or PascalCase.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInfo {
  /// The user's unique identifier.
  #[serde(rename = "UserId", alias = "userId")]
  pub user_id:        i64,
  /// The user's username.
  #[serde(rename = "UserName", alias = "userName")]
  pub user_name:      String,
  /// The user's email address.
  #[serde(rename = "Email", alias = "email")]
  pub email:          String,
  /// Whether the email has been verified.
  #[serde(rename = "EmailVerified", alias = "emailVerified")]
  pub email_verified: bool,
  /// The user's account ID.
  #[serde(rename = "AccountId", alias = "accountId")]
  pub account_id:     i64,
  /// The OMS (Order Management System) ID.
  #[serde(rename = "OMSId", alias = "omsId")]
  pub oms_id:         i64,
  /// Whether the user has 2FA enabled.
  #[serde(rename = "Use2FA", alias = "use2FA")]
  pub use_2fa:        bool,
}

/// Creates an authentication request from credentials.
///
/// Generates a nonce from the current Unix timestamp in milliseconds.
///
/// # Arguments
///
/// * `credentials` - The NDAX API credentials
///
/// # Returns
///
/// An `AuthenticateUserRequest` ready to be sent.
pub fn create_auth_request(
  credentials: &NdaxCredentials,
) -> AuthenticateUserRequest {
  let nonce = SystemTime::now()
    .duration_since(UNIX_EPOCH)
    .expect("Time went backwards")
    .as_millis()
    .to_string();

  let signature = generate_signature(
    &nonce,
    &credentials.user_id,
    &credentials.api_key,
    &credentials.api_secret,
  );

  AuthenticateUserRequest {
    api_key: credentials.api_key.clone(),
    signature,
    user_id: credentials.user_id.clone(),
    nonce,
  }
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn test_generate_signature_known_values() {
    // Test with fake test values (not real credentials)
    let nonce = "1234567890123";
    let user_id = "123456";
    let api_key = "00000000000000000000000000000000";
    let api_secret = "11111111111111111111111111111111";

    let signature = generate_signature(nonce, user_id, api_key, api_secret);

    // Verify signature is lowercase hex and correct length (64 chars for
    // SHA256)
    assert_eq!(signature.len(), 64);
    assert!(signature.chars().all(|c| c.is_ascii_hexdigit()));
    assert_eq!(signature, signature.to_lowercase());

    // Verify deterministic output
    let signature2 = generate_signature(nonce, user_id, api_key, api_secret);
    assert_eq!(signature, signature2);

    // Verify signature matches expected HMAC-SHA256 output
    // Message: nonce + user_id + api_key =
    // "123456789012312345600000000000000000000000000000000000"
    // Key: "11111111111111111111111111111111"
    // Computed with: printf '%s%s%s' "1234567890123" "123456"
    // "00000000000000000000000000000000" | openssl dgst -sha256 -hmac
    // "11111111111111111111111111111111"
    assert_eq!(
      signature,
      "d064ba829f7310653796770eb33f557deedebabb591d546f0add3600fab1ccda"
    );
  }

  #[test]
  fn test_auth_request_serialize() {
    let request = AuthenticateUserRequest {
      api_key:   "test_key".to_string(),
      signature: "abc123".to_string(),
      user_id:   "12345".to_string(),
      nonce:     "1709123456789".to_string(),
    };

    let json = serde_json::to_string(&request).unwrap();

    // Verify PascalCase field names in JSON
    assert!(json.contains("\"APIKey\""));
    assert!(json.contains("\"Signature\""));
    assert!(json.contains("\"UserId\""));
    assert!(json.contains("\"Nonce\""));

    // Verify values
    assert!(json.contains("\"test_key\""));
    assert!(json.contains("\"abc123\""));
    assert!(json.contains("\"12345\""));
    assert!(json.contains("\"1709123456789\""));
  }

  #[test]
  fn test_auth_response_deserialize() {
    let json = r#"{
            "authenticated": true,
            "locked": false,
            "requires2FA": false,
            "twoFAType": null,
            "twoFAToken": null,
            "user": {
                "UserId": 12345,
                "UserName": "testuser",
                "Email": "test@example.com",
                "EmailVerified": true,
                "AccountId": 67890,
                "OMSId": 1,
                "Use2FA": false
            }
        }"#;

    let response: AuthenticateUserResponse =
      serde_json::from_str(json).unwrap();

    assert!(response.authenticated);
    assert!(!response.locked);
    assert!(!response.requires_2fa);
    assert!(response.two_fa_type.is_none());
    assert!(response.two_fa_token.is_none());

    let user = response.user.unwrap();
    assert_eq!(user.user_id, 12345);
    assert_eq!(user.user_name, "testuser");
    assert_eq!(user.email, "test@example.com");
    assert!(user.email_verified);
    assert_eq!(user.account_id, 67890);
    assert_eq!(user.oms_id, 1);
    assert!(!user.use_2fa);
  }

  #[test]
  fn test_create_auth_request() {
    let credentials =
      NdaxCredentials::new("my_api_key", "my_api_secret", "12345");

    let request = create_auth_request(&credentials);

    assert_eq!(request.api_key, "my_api_key");
    assert_eq!(request.user_id, "12345");
    // Nonce should be a valid timestamp (milliseconds since epoch)
    let nonce: u128 = request.nonce.parse().unwrap();
    assert!(nonce > 1700000000000); // Sanity check: after 2023
                                    // Signature should be 64 hex chars
    assert_eq!(request.signature.len(), 64);
    assert!(request.signature.chars().all(|c| c.is_ascii_hexdigit()));
  }

  #[test]
  fn test_user_info_serialize_deserialize() {
    let user = UserInfo {
      user_id:        12345,
      user_name:      "testuser".to_string(),
      email:          "test@example.com".to_string(),
      email_verified: true,
      account_id:     67890,
      oms_id:         1,
      use_2fa:        false,
    };

    let json = serde_json::to_string(&user).unwrap();

    // Verify PascalCase field names
    assert!(json.contains("\"UserId\""));
    assert!(json.contains("\"UserName\""));
    assert!(json.contains("\"Email\""));
    assert!(json.contains("\"EmailVerified\""));
    assert!(json.contains("\"AccountId\""));
    assert!(json.contains("\"OMSId\""));
    assert!(json.contains("\"Use2FA\""));

    // Verify round-trip
    let deserialized: UserInfo = serde_json::from_str(&json).unwrap();
    assert_eq!(deserialized.user_id, user.user_id);
    assert_eq!(deserialized.user_name, user.user_name);
  }
}