nautilus-binance 0.55.0

Binance exchange integration adapter for the Nautilus trading engine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
// -------------------------------------------------------------------------------------------------
//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
//  https://nautechsystems.io
//
//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
//  You may not use this file except in compliance with the License.
//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
//
//  Unless required by applicable law or agreed to in writing, software
//  distributed under the License is distributed on an "AS IS" BASIS,
//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//  See the License for the specific language governing permissions and
//  limitations under the License.
// -------------------------------------------------------------------------------------------------

//! Binance API credential handling and request signing.
//!
//! This module provides two types of credentials:
//! - [`Credential`]: HMAC SHA256 signing for REST API and standard WebSocket
//! - [`Ed25519Credential`]: Ed25519 signing for WebSocket API and SBE streams
//!
//! Credentials are resolved from standard environment variables
//! (`BINANCE_API_KEY`/`BINANCE_API_SECRET`). The deprecated `*_ED25519_*`
//! variables are no longer supported and will produce a clear error.

#![allow(unused_assignments)] // Fields are used in methods; false positive on some toolchains

use std::fmt::{Debug, Display};

use aws_lc_rs::hmac;
use ed25519_dalek::{Signature, Signer, SigningKey};
use nautilus_core::{hex, string::REDACTED};
use zeroize::ZeroizeOnDrop;

use super::enums::{BinanceEnvironment, BinanceProductType};

/// Resolves API credentials from config or environment variables.
///
/// Checks standard environment variables:
/// - Live: `BINANCE_API_KEY` / `BINANCE_API_SECRET`
/// - Testnet (Spot): `BINANCE_TESTNET_API_KEY` / `BINANCE_TESTNET_API_SECRET`
/// - Testnet (Futures): `BINANCE_FUTURES_TESTNET_API_KEY` / `BINANCE_FUTURES_TESTNET_API_SECRET`
/// - Demo: `BINANCE_DEMO_API_KEY` / `BINANCE_DEMO_API_SECRET`
///
/// The deprecated `*_ED25519_*` environment variables are no longer supported.
/// If detected, a clear error is returned with migration instructions.
///
/// # Errors
///
/// Returns an error if credentials cannot be resolved from config or environment.
pub fn resolve_credentials(
    config_api_key: Option<String>,
    config_api_secret: Option<String>,
    environment: BinanceEnvironment,
    product_type: BinanceProductType,
) -> anyhow::Result<(String, String)> {
    if let (Some(key), Some(secret)) = (config_api_key.clone(), config_api_secret.clone()) {
        return Ok((key, secret));
    }

    let (deprecated_key_var, deprecated_secret_var, standard_key_var, standard_secret_var) =
        match environment {
            BinanceEnvironment::Testnet => match product_type {
                BinanceProductType::Spot
                | BinanceProductType::Margin
                | BinanceProductType::Options => (
                    "BINANCE_TESTNET_ED25519_API_KEY",
                    "BINANCE_TESTNET_ED25519_API_SECRET",
                    "BINANCE_TESTNET_API_KEY",
                    "BINANCE_TESTNET_API_SECRET",
                ),
                BinanceProductType::UsdM | BinanceProductType::CoinM => (
                    "BINANCE_FUTURES_TESTNET_ED25519_API_KEY",
                    "BINANCE_FUTURES_TESTNET_ED25519_API_SECRET",
                    "BINANCE_FUTURES_TESTNET_API_KEY",
                    "BINANCE_FUTURES_TESTNET_API_SECRET",
                ),
            },

            // Demo shares API keys across all product types
            BinanceEnvironment::Demo => ("", "", "BINANCE_DEMO_API_KEY", "BINANCE_DEMO_API_SECRET"),
            BinanceEnvironment::Mainnet => (
                "BINANCE_ED25519_API_KEY",
                "BINANCE_ED25519_API_SECRET",
                "BINANCE_API_KEY",
                "BINANCE_API_SECRET",
            ),
        };

    // Futures: soft deprecation (warn + fallback),
    // Spot/Margin: hard error on removed env vars.
    let is_futures = matches!(
        product_type,
        BinanceProductType::UsdM | BinanceProductType::CoinM
    );

    let api_key = config_api_key
        .or_else(|| std::env::var(standard_key_var).ok())
        .or_else(|| resolve_deprecated_var(deprecated_key_var, standard_key_var, is_futures))
        .ok_or_else(|| anyhow::anyhow!("{standard_key_var} not found in config or environment"))?;

    let api_secret = config_api_secret
        .or_else(|| std::env::var(standard_secret_var).ok())
        .or_else(|| resolve_deprecated_var(deprecated_secret_var, standard_secret_var, is_futures))
        .ok_or_else(|| {
            anyhow::anyhow!("{standard_secret_var} not found in config or environment")
        })?;

    Ok((api_key, api_secret))
}

fn resolve_deprecated_var(
    deprecated_var: &str,
    standard_var: &str,
    allow_fallback: bool,
) -> Option<String> {
    if deprecated_var.is_empty() {
        return None;
    }

    let value = std::env::var(deprecated_var).ok()?;

    if allow_fallback {
        log::warn!(
            "'{deprecated_var}' is deprecated and will be removed in a future version. \
             Rename it to '{standard_var}' (Ed25519 keys are now auto-detected)"
        );
        Some(value)
    } else {
        log::error!(
            "'{deprecated_var}' has been removed. \
             Rename it to '{standard_var}' (Ed25519 keys are now auto-detected)"
        );
        None
    }
}

/// Binance API credentials for signing requests (HMAC SHA256).
///
/// Uses HMAC SHA256 with hexadecimal encoding, as required by Binance REST API signing.
#[derive(Clone, ZeroizeOnDrop)]
pub struct Credential {
    api_key: Box<str>,
    api_secret: Box<[u8]>,
}

/// Binance Ed25519 credentials for WebSocket API authentication.
///
/// Ed25519 is required for WebSocket API authentication (`session.logon`).
/// This is the only key type supported for execution clients.
#[derive(ZeroizeOnDrop)]
pub struct Ed25519Credential {
    api_key: Box<str>,
    signing_key: SigningKey,
}

impl Debug for Credential {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct(stringify!(Credential))
            .field("api_key", &self.api_key)
            .field("api_secret", &REDACTED)
            .finish()
    }
}

impl Credential {
    /// Creates a new [`Credential`] instance.
    #[must_use]
    pub fn new(api_key: String, api_secret: String) -> Self {
        Self {
            api_key: api_key.into_boxed_str(),
            api_secret: api_secret.into_bytes().into_boxed_slice(),
        }
    }

    /// Returns the API key.
    #[must_use]
    pub fn api_key(&self) -> &str {
        &self.api_key
    }

    /// Signs a message with HMAC SHA256 and returns a lowercase hex digest.
    #[must_use]
    pub fn sign(&self, message: &str) -> String {
        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.api_secret);
        let tag = hmac::sign(&key, message.as_bytes());
        hex::encode(tag.as_ref())
    }
}

impl Debug for Ed25519Credential {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct(stringify!(Ed25519Credential))
            .field("api_key", &self.api_key)
            .field("signing_key", &REDACTED)
            .finish()
    }
}

impl Ed25519Credential {
    /// Creates a new [`Ed25519Credential`] from API key and base64-encoded private key.
    ///
    /// The private key can be provided as:
    /// - Raw 32-byte seed (base64 encoded)
    /// - PKCS#8 DER format (48 bytes, as generated by OpenSSL)
    /// - PEM format (with or without headers)
    ///
    /// For PKCS#8/PEM format, the 32-byte seed is extracted from the last 32 bytes.
    ///
    /// # Errors
    ///
    /// Returns an error if the private key is not valid base64 or not a valid
    /// Ed25519 private key.
    pub fn new(api_key: String, private_key_base64: &str) -> Result<Self, Ed25519CredentialError> {
        // Strip PEM headers/footers if present
        let key_data: String = private_key_base64
            .lines()
            .filter(|line| !line.starts_with("-----"))
            .collect();

        let private_key_bytes =
            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &key_data)
                .map_err(|e| Ed25519CredentialError::InvalidBase64(e.to_string()))?;

        // Extract 32-byte seed: works for both raw (32 bytes) and PKCS#8 (48 bytes)
        if private_key_bytes.len() < 32 {
            return Err(Ed25519CredentialError::InvalidKeyLength);
        }
        let seed_start = private_key_bytes.len() - 32;
        let key_bytes: [u8; 32] = private_key_bytes[seed_start..]
            .try_into()
            .map_err(|_| Ed25519CredentialError::InvalidKeyLength)?;

        let signing_key = SigningKey::from_bytes(&key_bytes);

        Ok(Self {
            api_key: api_key.into_boxed_str(),
            signing_key,
        })
    }

    /// Returns the API key.
    #[must_use]
    pub fn api_key(&self) -> &str {
        &self.api_key
    }

    /// Signs a message with Ed25519 and returns a base64-encoded signature.
    #[must_use]
    pub fn sign(&self, message: &[u8]) -> String {
        let signature: Signature = self.signing_key.sign(message);
        base64::Engine::encode(
            &base64::engine::general_purpose::STANDARD,
            signature.to_bytes(),
        )
    }
}

/// Error type for Ed25519 credential creation.
#[derive(Debug, Clone)]
pub enum Ed25519CredentialError {
    /// The private key is not valid base64.
    InvalidBase64(String),
    /// The private key is not 32 bytes.
    InvalidKeyLength,
}

impl Display for Ed25519CredentialError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::InvalidBase64(e) => write!(f, "Invalid base64 encoding: {e}"),
            Self::InvalidKeyLength => write!(f, "Ed25519 private key must be 32 bytes"),
        }
    }
}

impl std::error::Error for Ed25519CredentialError {}

/// Unified signing credential that auto-detects Ed25519 vs HMAC key type.
///
/// Binance supports two signing methods:
/// - HMAC SHA256 (hex-encoded signature) for REST API and standard WebSocket
/// - Ed25519 (base64-encoded signature) for WebSocket API and SBE streams
///
/// The key type is detected from the secret format: if the secret decodes as
/// valid base64 with 32+ bytes (raw seed or PKCS#8), Ed25519 is used.
/// Otherwise HMAC is used.
#[derive(Clone)]
pub enum SigningCredential {
    /// HMAC SHA256 signing.
    Hmac(Credential),
    /// Ed25519 signing.
    Ed25519(Box<Ed25519Credential>),
}

impl Debug for SigningCredential {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Hmac(c) => f.debug_tuple("Hmac").field(c).finish(),
            Self::Ed25519(c) => f.debug_tuple("Ed25519").field(c).finish(),
        }
    }
}

impl SigningCredential {
    /// Creates a new signing credential, auto-detecting Ed25519 vs HMAC.
    ///
    /// Tries Ed25519 first (base64-decoded secret must be a valid Ed25519 key).
    /// Falls back to HMAC if Ed25519 parsing fails.
    #[must_use]
    pub fn new(api_key: String, api_secret: String) -> Self {
        match Ed25519Credential::new(api_key.clone(), &api_secret) {
            Ok(ed25519) => {
                log::info!("Auto-detected Ed25519 API key");
                Self::Ed25519(Box::new(ed25519))
            }
            Err(_) => {
                log::info!("Using HMAC SHA256 API key");
                Self::Hmac(Credential::new(api_key, api_secret))
            }
        }
    }

    /// Returns the API key.
    #[must_use]
    pub fn api_key(&self) -> &str {
        match self {
            Self::Hmac(c) => c.api_key(),
            Self::Ed25519(c) => c.api_key(),
        }
    }

    /// Signs a message string and returns the signature.
    ///
    /// For HMAC: returns lowercase hex digest.
    /// For Ed25519: returns base64-encoded signature.
    #[must_use]
    pub fn sign(&self, message: &str) -> String {
        match self {
            Self::Hmac(c) => c.sign(message),
            Self::Ed25519(c) => c.sign(message.as_bytes()),
        }
    }

    /// Returns whether this credential uses Ed25519 signing.
    #[must_use]
    pub fn is_ed25519(&self) -> bool {
        matches!(self, Self::Ed25519(_))
    }
}

// Ed25519Credential does not implement Clone because SigningKey doesn't.
// Provide a manual Clone for SigningCredential by re-deriving keys.
impl Clone for Ed25519Credential {
    fn clone(&self) -> Self {
        // SigningKey is 32 bytes; extract and reconstruct
        let key_bytes = self.signing_key.to_bytes();
        Self {
            api_key: self.api_key.clone(),
            signing_key: SigningKey::from_bytes(&key_bytes),
        }
    }
}

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

    use super::*;

    // Official Binance test vectors from:
    // https://github.com/binance/binance-signature-examples
    const BINANCE_TEST_SECRET: &str =
        "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j";

    #[rstest]
    fn test_sign_matches_binance_test_vector_simple() {
        let cred = Credential::new("test_key".to_string(), BINANCE_TEST_SECRET.to_string());
        let message = "timestamp=1578963600000";
        let expected = "d84e6641b1e328e7b418fff030caed655c266299c9355e36ce801ed14631eed4";

        assert_eq!(cred.sign(message), expected);
    }

    #[rstest]
    fn test_sign_matches_binance_test_vector_order() {
        let cred = Credential::new("test_key".to_string(), BINANCE_TEST_SECRET.to_string());
        let message = "symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000&timestamp=1499827319559";
        let expected = "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71";

        assert_eq!(cred.sign(message), expected);
    }

    #[rstest]
    fn test_debug_redacts_secret() {
        let cred = Credential::new("test_key".to_string(), BINANCE_TEST_SECRET.to_string());
        let dbg_out = format!("{cred:?}");

        assert!(dbg_out.contains(REDACTED));
        assert!(!dbg_out.contains("NhqPtmdSJYdKjVHjA7PZj4"));
    }

    #[rstest]
    fn test_ed25519_debug_redacts_secret() {
        // 32-byte seed encoded as base64
        let seed = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [0xABu8; 32]);
        let cred = Ed25519Credential::new("test_key".to_string(), &seed).unwrap();
        let dbg_out = format!("{cred:?}");

        assert!(dbg_out.contains(REDACTED));
        assert!(!dbg_out.contains(&seed));
    }
}