rusty_feeder/exchange/upbit/
auth.rs1use anyhow::{Result, anyhow};
7use hmac::Hmac;
8use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256, Sha512};
11use smartstring::{SmartString, alias::String};
12use uuid::Uuid;
13
14type HmacSha256 = Hmac<Sha256>;
16
17#[derive(Debug, Serialize, Deserialize)]
19pub struct UpbitJwtClaims {
20 pub access_key: String,
22
23 pub nonce: String,
25
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub query_hash: Option<String>,
29
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub body_hash: Option<String>,
33}
34
35#[derive(Debug, Clone)]
37pub struct UpbitAuthConfig {
38 pub access_key: String,
40
41 pub secret_key: String,
43}
44
45impl UpbitAuthConfig {
46 #[must_use]
48 pub const fn new(access_key: String, secret_key: String) -> Self {
49 Self {
50 access_key,
51 secret_key,
52 }
53 }
54}
55
56#[derive(Debug)]
58pub struct UpbitAuth {
59 config: UpbitAuthConfig,
61}
62
63impl UpbitAuth {
64 #[must_use]
66 pub const fn new(config: UpbitAuthConfig) -> Self {
67 Self { config }
68 }
69
70 pub fn generate_websocket_jwt(&self) -> Result<String> {
75 let claims = UpbitJwtClaims {
77 access_key: self.config.access_key.clone(),
78 nonce: String::from(Uuid::new_v4().to_string()),
79 query_hash: None,
80 body_hash: None,
81 };
82
83 self.encode_jwt(&claims)
84 }
85
86 pub fn generate_rest_jwt_get(&self, query_params: Option<&[(&str, &str)]>) -> Result<String> {
94 let mut claims = UpbitJwtClaims {
95 access_key: self.config.access_key.clone(),
96 nonce: String::from(Uuid::new_v4().to_string()),
97 query_hash: None,
98 body_hash: None,
99 };
100
101 if let Some(params) = query_params
103 && !params.is_empty()
104 {
105 let query_string = self.build_sorted_query_string(params);
106 let query_hash = self.calculate_sha512_hash(&query_string);
107 claims.query_hash = Some(query_hash);
108 }
109
110 self.encode_jwt(&claims)
111 }
112
113 pub fn generate_rest_jwt_post(&self, body: &str) -> Result<String> {
121 let claims = UpbitJwtClaims {
122 access_key: self.config.access_key.clone(),
123 nonce: String::from(Uuid::new_v4().to_string()),
124 query_hash: None,
125 body_hash: Some(self.calculate_sha512_hash(body)),
126 };
127
128 self.encode_jwt(&claims)
129 }
130
131 fn build_sorted_query_string(&self, params: &[(&str, &str)]) -> String {
139 let mut params_vec: Vec<(&str, &str)> = params.to_vec();
140 params_vec.sort_by(|a, b| a.0.cmp(b.0)); params_vec
143 .iter()
144 .map(|(k, v)| format!("{k}={v}").into())
145 .collect::<Vec<String>>()
146 .join("&")
147 .into()
148 }
149
150 fn calculate_sha512_hash(&self, data: &str) -> String {
158 let mut hasher = Sha512::new();
159 hasher.update(data.as_bytes());
160 hex::encode(hasher.finalize()).into()
161 }
162
163 fn encode_jwt(&self, claims: &UpbitJwtClaims) -> Result<String> {
171 let header = Header::new(Algorithm::HS256);
173
174 let encoding_key = EncodingKey::from_secret(self.config.secret_key.as_bytes());
176
177 encode(&header, claims, &encoding_key)
179 .map(SmartString::from)
180 .map_err(|e| anyhow!("Failed to encode JWT: {}", e))
181 }
182}
183
184pub fn add_jwt_to_websocket_request(
188 headers: &mut rusty_common::collections::FxHashMap<String, String>,
189 jwt_token: &str,
190) -> Result<()> {
191 headers.insert("Authorization".into(), format!("Bearer {jwt_token}").into());
193
194 Ok(())
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn test_jwt_generation() {
203 let config = UpbitAuthConfig::new("test_access_key".into(), "test_secret_key".into());
205
206 let auth = UpbitAuth::new(config);
207
208 let ws_jwt = auth.generate_websocket_jwt().unwrap();
210 assert!(!ws_jwt.is_empty());
211
212 let get_jwt = auth.generate_rest_jwt_get(None).unwrap();
214 assert!(!get_jwt.is_empty());
215
216 let params = vec![("market", "KRW-BTC"), ("state", "wait")];
218 let get_jwt_with_params = auth.generate_rest_jwt_get(Some(¶ms)).unwrap();
219 assert!(!get_jwt_with_params.is_empty());
220
221 let body = r#"{"market":"KRW-BTC","side":"bid","volume":"0.01","price":"20000000","ord_type":"limit"}"#;
223 let post_jwt = auth.generate_rest_jwt_post(body).unwrap();
224 assert!(!post_jwt.is_empty());
225 }
226
227 #[test]
228 fn test_query_string_sorting() {
229 let config = UpbitAuthConfig::new("test_access_key".into(), "test_secret_key".into());
230
231 let auth = UpbitAuth::new(config);
232
233 let params = vec![("state", "wait"), ("market", "KRW-BTC"), ("uuids", "uuid1")];
235 let sorted = auth.build_sorted_query_string(¶ms);
236 assert_eq!(sorted, "market=KRW-BTC&state=wait&uuids=uuid1");
237 }
238
239 #[test]
240 fn test_sha512_hash() {
241 let config = UpbitAuthConfig::new("test_access_key".into(), "test_secret_key".into());
242
243 let auth = UpbitAuth::new(config);
244
245 let data = "test_data";
247 let hash = auth.calculate_sha512_hash(data);
248
249 assert_eq!(hash.len(), 128);
251
252 let expected_hash = "66f993ac0f65e1a301f4924cc38fc4da1d6ac7c53c3e75e535735788263d97f50a323cec0b033e28a95a4549a5d39a45f85d6f6fb7b02b75e3d5de5e9727a3ea";
254 assert_eq!(hash, expected_hash);
255 }
256}