1use base64::{Engine as _, engine::general_purpose};
11use ccxt_core::credentials::SecretString;
12use hmac::{Hmac, Mac};
13use reqwest::header::{HeaderMap, HeaderValue};
14use sha2::Sha256;
15
16#[derive(Debug, Clone)]
23pub struct BitgetAuth {
24 api_key: SecretString,
26 secret: SecretString,
28 passphrase: SecretString,
30}
31
32impl BitgetAuth {
33 pub fn new(api_key: String, secret: String, passphrase: String) -> Self {
57 Self {
58 api_key: SecretString::new(api_key),
59 secret: SecretString::new(secret),
60 passphrase: SecretString::new(passphrase),
61 }
62 }
63
64 pub fn api_key(&self) -> &str {
66 self.api_key.expose_secret()
67 }
68
69 pub fn passphrase(&self) -> &str {
71 self.passphrase.expose_secret()
72 }
73
74 pub fn build_sign_string(
89 &self,
90 timestamp: &str,
91 method: &str,
92 path: &str,
93 body: &str,
94 ) -> String {
95 format!("{}{}{}{}", timestamp, method.to_uppercase(), path, body)
96 }
97
98 pub fn sign(&self, timestamp: &str, method: &str, path: &str, body: &str) -> String {
126 let sign_string = self.build_sign_string(timestamp, method, path, body);
127 self.hmac_sha256_base64(&sign_string)
128 }
129
130 fn hmac_sha256_base64(&self, message: &str) -> String {
132 type HmacSha256 = Hmac<Sha256>;
133
134 let mut mac = HmacSha256::new_from_slice(self.secret.expose_secret_bytes())
136 .expect("HMAC-SHA256 accepts keys of any length; this is an infallible operation");
137 mac.update(message.as_bytes());
138 let result = mac.finalize().into_bytes();
139
140 general_purpose::STANDARD.encode(result)
141 }
142
143 pub fn add_auth_headers(&self, headers: &mut HeaderMap, timestamp: &str, sign: &str) {
180 headers.insert(
181 "ACCESS-KEY",
182 HeaderValue::from_str(self.api_key.expose_secret())
183 .unwrap_or_else(|_| HeaderValue::from_static("")),
184 );
185 headers.insert(
186 "ACCESS-SIGN",
187 HeaderValue::from_str(sign).unwrap_or_else(|_| HeaderValue::from_static("")),
188 );
189 headers.insert(
190 "ACCESS-TIMESTAMP",
191 HeaderValue::from_str(timestamp).unwrap_or_else(|_| HeaderValue::from_static("")),
192 );
193 headers.insert(
194 "ACCESS-PASSPHRASE",
195 HeaderValue::from_str(self.passphrase.expose_secret())
196 .unwrap_or_else(|_| HeaderValue::from_static("")),
197 );
198 }
199
200 pub fn create_auth_headers(
215 &self,
216 timestamp: &str,
217 method: &str,
218 path: &str,
219 body: &str,
220 ) -> HeaderMap {
221 let sign = self.sign(timestamp, method, path, body);
222 let mut headers = HeaderMap::new();
223 self.add_auth_headers(&mut headers, timestamp, &sign);
224 headers
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn test_auth_new() {
234 let auth = BitgetAuth::new(
235 "test-api-key".to_string(),
236 "test-secret".to_string(),
237 "test-passphrase".to_string(),
238 );
239
240 assert_eq!(auth.api_key(), "test-api-key");
241 assert_eq!(auth.passphrase(), "test-passphrase");
242 }
243
244 #[test]
245 fn test_build_sign_string() {
246 let auth = BitgetAuth::new(
247 "api-key".to_string(),
248 "secret".to_string(),
249 "passphrase".to_string(),
250 );
251
252 let sign_string =
253 auth.build_sign_string("1234567890", "GET", "/api/v2/spot/account/assets", "");
254 assert_eq!(sign_string, "1234567890GET/api/v2/spot/account/assets");
255
256 let sign_string_post = auth.build_sign_string(
257 "1234567890",
258 "POST",
259 "/api/v2/spot/trade/place-order",
260 r#"{"symbol":"BTCUSDT","side":"buy"}"#,
261 );
262 assert_eq!(
263 sign_string_post,
264 r#"1234567890POST/api/v2/spot/trade/place-order{"symbol":"BTCUSDT","side":"buy"}"#
265 );
266 }
267
268 #[test]
269 fn test_sign_deterministic() {
270 let auth = BitgetAuth::new(
271 "api-key".to_string(),
272 "test-secret-key".to_string(),
273 "passphrase".to_string(),
274 );
275
276 let sig1 = auth.sign("1234567890", "GET", "/api/v2/spot/account/assets", "");
277 let sig2 = auth.sign("1234567890", "GET", "/api/v2/spot/account/assets", "");
278
279 assert_eq!(sig1, sig2);
280 assert!(!sig1.is_empty());
281 }
282
283 #[test]
284 fn test_sign_different_inputs() {
285 let auth = BitgetAuth::new(
286 "api-key".to_string(),
287 "test-secret-key".to_string(),
288 "passphrase".to_string(),
289 );
290
291 let sig1 = auth.sign("1234567890", "GET", "/api/v2/spot/account/assets", "");
292 let sig2 = auth.sign("1234567891", "GET", "/api/v2/spot/account/assets", "");
293 let sig3 = auth.sign("1234567890", "POST", "/api/v2/spot/account/assets", "");
294
295 assert_ne!(sig1, sig2);
296 assert_ne!(sig1, sig3);
297 }
298
299 #[test]
300 fn test_add_auth_headers() {
301 let auth = BitgetAuth::new(
302 "test-api-key".to_string(),
303 "test-secret".to_string(),
304 "test-passphrase".to_string(),
305 );
306
307 let mut headers = HeaderMap::new();
308 let timestamp = "1234567890";
309 let signature = auth.sign(timestamp, "GET", "/api/v2/spot/account/assets", "");
310 auth.add_auth_headers(&mut headers, timestamp, &signature);
311
312 assert_eq!(headers.get("ACCESS-KEY").unwrap(), "test-api-key");
313 assert_eq!(headers.get("ACCESS-SIGN").unwrap(), &signature);
314 assert_eq!(headers.get("ACCESS-TIMESTAMP").unwrap(), "1234567890");
315 assert_eq!(headers.get("ACCESS-PASSPHRASE").unwrap(), "test-passphrase");
316 }
317
318 #[test]
319 fn test_create_auth_headers() {
320 let auth = BitgetAuth::new(
321 "test-api-key".to_string(),
322 "test-secret".to_string(),
323 "test-passphrase".to_string(),
324 );
325
326 let headers =
327 auth.create_auth_headers("1234567890", "GET", "/api/v2/spot/account/assets", "");
328
329 assert!(headers.contains_key("ACCESS-KEY"));
330 assert!(headers.contains_key("ACCESS-SIGN"));
331 assert!(headers.contains_key("ACCESS-TIMESTAMP"));
332 assert!(headers.contains_key("ACCESS-PASSPHRASE"));
333 }
334
335 #[test]
336 fn test_method_case_insensitive() {
337 let auth = BitgetAuth::new(
338 "api-key".to_string(),
339 "test-secret".to_string(),
340 "passphrase".to_string(),
341 );
342
343 let sign_string_lower =
345 auth.build_sign_string("1234567890", "get", "/api/v2/spot/account/assets", "");
346 let sign_string_upper =
347 auth.build_sign_string("1234567890", "GET", "/api/v2/spot/account/assets", "");
348
349 assert_eq!(sign_string_lower, sign_string_upper);
350 }
351
352 #[test]
353 fn test_signature_is_base64() {
354 let auth = BitgetAuth::new(
355 "api-key".to_string(),
356 "test-secret".to_string(),
357 "passphrase".to_string(),
358 );
359
360 let signature = auth.sign("1234567890", "GET", "/api/v2/spot/account/assets", "");
361
362 assert!(
364 signature
365 .chars()
366 .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
367 );
368
369 let decoded = general_purpose::STANDARD.decode(&signature);
371 assert!(decoded.is_ok());
372
373 assert_eq!(decoded.unwrap().len(), 32);
375 }
376
377 #[test]
384 fn test_signature_with_known_inputs() {
385 let auth = BitgetAuth::new(
386 "bg_api_key".to_string(),
387 "bitget-secret-key".to_string(),
388 "my-passphrase".to_string(),
389 );
390
391 let timestamp = "1609459200000"; let method = "GET";
394 let path = "/api/v2/spot/account/assets";
395 let body = "";
396
397 let signature = auth.sign(timestamp, method, path, body);
399
400 let expected_sign_string = "1609459200000GET/api/v2/spot/account/assets";
402 assert_eq!(
403 auth.build_sign_string(timestamp, method, path, body),
404 expected_sign_string
405 );
406
407 assert!(!signature.is_empty());
409 let decoded = general_purpose::STANDARD.decode(&signature);
410 assert!(decoded.is_ok());
411 assert_eq!(decoded.unwrap().len(), 32);
412
413 let signature2 = auth.sign(timestamp, method, path, body);
415 assert_eq!(signature, signature2);
416 }
417
418 #[test]
420 fn test_signature_with_post_body() {
421 let auth = BitgetAuth::new(
422 "bg_api_key".to_string(),
423 "bitget-secret-key".to_string(),
424 "my-passphrase".to_string(),
425 );
426
427 let timestamp = "1609459200000";
428 let method = "POST";
429 let path = "/api/v2/spot/trade/place-order";
430 let body = r#"{"symbol":"BTCUSDT","side":"buy","orderType":"limit","price":"50000","size":"0.001"}"#;
431
432 let signature = auth.sign(timestamp, method, path, body);
433
434 let expected_sign_string = format!("{}{}{}{}", timestamp, method, path, body);
436 assert_eq!(
437 auth.build_sign_string(timestamp, method, path, body),
438 expected_sign_string
439 );
440
441 assert!(!signature.is_empty());
443 let decoded = general_purpose::STANDARD.decode(&signature);
444 assert!(decoded.is_ok());
445 assert_eq!(decoded.unwrap().len(), 32);
446 }
447
448 #[test]
450 fn test_header_values_correctness() {
451 let api_key = "my-api-key-12345";
452 let secret = "my-secret-key-67890";
453 let passphrase = "my-passphrase-abc";
454
455 let auth = BitgetAuth::new(
456 api_key.to_string(),
457 secret.to_string(),
458 passphrase.to_string(),
459 );
460
461 let timestamp = "1609459200000";
462 let method = "GET";
463 let path = "/api/v2/spot/account/assets";
464 let body = "";
465
466 let headers = auth.create_auth_headers(timestamp, method, path, body);
467
468 assert_eq!(
470 headers.get("ACCESS-KEY").unwrap().to_str().unwrap(),
471 api_key
472 );
473
474 assert_eq!(
476 headers.get("ACCESS-TIMESTAMP").unwrap().to_str().unwrap(),
477 timestamp
478 );
479
480 assert_eq!(
482 headers.get("ACCESS-PASSPHRASE").unwrap().to_str().unwrap(),
483 passphrase
484 );
485
486 let sign_header = headers.get("ACCESS-SIGN").unwrap().to_str().unwrap();
488 assert!(!sign_header.is_empty());
489 let decoded = general_purpose::STANDARD.decode(sign_header);
490 assert!(decoded.is_ok());
491 }
492
493 #[test]
495 fn test_signature_with_query_params() {
496 let auth = BitgetAuth::new(
497 "api-key".to_string(),
498 "secret-key".to_string(),
499 "passphrase".to_string(),
500 );
501
502 let timestamp = "1609459200000";
503 let method = "GET";
504 let path = "/api/v2/spot/market/tickers?symbol=BTCUSDT";
505 let body = "";
506
507 let signature = auth.sign(timestamp, method, path, body);
508
509 let sign_string = auth.build_sign_string(timestamp, method, path, body);
511 assert!(sign_string.contains("?symbol=BTCUSDT"));
512
513 assert!(!signature.is_empty());
515 let decoded = general_purpose::STANDARD.decode(&signature);
516 assert!(decoded.is_ok());
517 }
518}