v_exchanges_adapters/exchanges/
mexc.rs

1// A module for communicating with the MEXC API (https://mexcdevelop.github.io/apidocs/spot/en/)
2
3use std::{marker::PhantomData, str::FromStr, time::SystemTime};
4
5use generics::{AuthError, UrlError};
6use hmac::{Hmac, Mac};
7use jiff::{SignedDuration, Timestamp};
8use secrecy::{ExposeSecret as _, SecretString};
9use serde::{Deserialize, Serialize, de::DeserializeOwned};
10use sha2::Sha256;
11use url::Url;
12use v_exchanges_api_generics::{http::*, ws::*};
13use v_utils::prelude::*;
14
15use crate::traits::*;
16
17static MAX_RECV_WINDOW: std::time::Duration = std::time::Duration::from_millis(60000); // as of (2025/01/18)
18
19/// Options that can be set when creating handlers
20#[derive(Debug, Default)]
21pub enum MexcOption {
22	/// Does nothing
23	#[default]
24	Default,
25	/// API key
26	Pubkey(String),
27	/// Api secret
28	Secret(SecretString),
29	/// Whether to make all requests to the testnet
30	Testnet(bool),
31	/// Base url for HTTP requests
32	HttpUrl(MexcHttpUrl),
33	/// Authentication type for HTTP requests
34	HttpAuth(MexcAuth),
35	/// receive window parameter used for requests
36	RecvWindow(std::time::Duration),
37	/// Base url for Ws connections
38	WsUrl(MexcWsUrl),
39	/// WsConfig used for creating WsConnections
40	WsConfig(WsConfig),
41	/// Topics to subscribe to on Ws connections
42	WsTopics(Vec<String>),
43}
44
45/// A struct that represents a set of MexcOptions
46#[derive(Clone, derive_more::Debug, Default)]
47pub struct MexcOptions {
48	/// see [MexcOption::Key]
49	pub pubkey: Option<String>,
50	/// see [MexcOption::Secret]
51	#[debug("[REDACTED]")]
52	pub secret: Option<SecretString>,
53	/// see [MexcOption::Testnet]
54	pub testnet: bool,
55	/// see [MexcOption::HttpUrl]
56	pub http_url: MexcHttpUrl,
57	/// see [MexcOption::HttpAuth]
58	pub http_auth: MexcAuth,
59	/// see [MexcOption::RecvWindow]
60	pub recv_window: Option<std::time::Duration>,
61	/// see [MexcOption::WsUrl]
62	pub ws_url: MexcWsUrl,
63	/// see [MexcOption::WsConfig]
64	pub ws_config: WsConfig,
65	/// see [MexcOption::WsTopics]
66	pub ws_topics: HashSet<String>,
67}
68
69/// Enum that represents the base url of the MEXC REST API
70#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
71#[non_exhaustive]
72pub enum MexcHttpUrl {
73	Spot,
74	Futures,
75	#[default]
76	None,
77}
78impl EndpointUrl for MexcHttpUrl {
79	fn url_mainnet(&self) -> Url {
80		match self {
81			Self::Spot => Url::parse("https://api.mexc.com").unwrap(),
82			Self::Futures => Url::parse("https://contract.mexc.com").unwrap(),
83			Self::None => Url::parse("").unwrap(),
84		}
85	}
86
87	fn url_testnet(&self) -> Option<Url> {
88		match self {
89			Self::Spot => Some(Url::parse("https://api-testnet.mexc.com").unwrap()),
90			Self::Futures => Some(Url::parse("https://contract-testnet.mexc.com").unwrap()),
91			Self::None => Some(Url::parse("").unwrap()),
92		}
93	}
94}
95
96#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
97pub enum MexcAuth {
98	Sign,
99	Key,
100	#[default]
101	None,
102}
103
104#[derive(Debug, Deserialize, Serialize)]
105pub struct MexcError {
106	pub code: i32,
107	pub msg: String,
108}
109impl From<MexcError> for ApiError {
110	fn from(e: MexcError) -> Self {
111		ApiError::Other(eyre!("MEXC API error: {}: {}", e.code, e.msg))
112	}
113}
114
115/// A struct that implements RequestHandler
116pub struct MexcRequestHandler<'a, R: DeserializeOwned> {
117	options: MexcOptions,
118	_phantom: PhantomData<&'a R>,
119}
120
121impl<B, R> RequestHandler<B> for MexcRequestHandler<'_, R>
122where
123	B: Serialize,
124	R: DeserializeOwned,
125{
126	type Successful = R;
127
128	fn base_url(&self, is_test: bool) -> Result<Url, UrlError> {
129		match is_test {
130			true => self.options.http_url.url_testnet().ok_or_else(|| UrlError::MissingTestnet(self.options.http_url.url_mainnet())),
131			false => Ok(self.options.http_url.url_mainnet()),
132		}
133	}
134
135	#[tracing::instrument(skip_all, fields(?builder))]
136	fn build_request(&self, mut builder: RequestBuilder, request_body: &Option<B>, _: u8) -> Result<Request, BuildError> {
137		if let Some(body) = request_body {
138			let encoded = serde_urlencoded::to_string(body)?;
139			builder = builder.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded").body(encoded);
140			//builder = builder.header(header::CONTENT_TYPE, "application/json");
141		}
142
143		if self.options.http_auth != MexcAuth::None {
144			let pubkey = self.options.pubkey.as_deref().ok_or(AuthError::MissingPubkey)?;
145			builder = builder.header("ApiKey", pubkey);
146
147			let time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap();
148			let timestamp = time.as_millis();
149			builder = builder.header("Request-Time", timestamp.to_string());
150
151			if let Some(recv_window) = self.options.recv_window {
152				builder = builder.header("Recv-Window", (recv_window.as_millis() as u64).to_string());
153			}
154
155			if self.options.http_auth == MexcAuth::Sign {
156				let secret = self.options.secret.as_ref().map(|s| s.expose_secret()).ok_or(AuthError::MissingSecret)?;
157				let mut hmac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
158
159				let mut request = builder.build().expect("My understanding is that this doesn't fail on client, so fail fast for dev");
160				let param_string = if request.method() == Method::GET || request.method() == Method::DELETE {
161					if let Some(body) = request_body { serde_urlencoded::to_string(body)? } else { String::new() }
162				} else {
163					// For POST, use body as JSON string
164					String::from_utf8(request.body().and_then(|body| body.as_bytes()).unwrap_or_default().to_vec()).unwrap_or_default()
165				};
166
167				let signature_base = format!("{pubkey}{timestamp}{param_string}");
168				hmac.update(signature_base.as_bytes());
169				let signature = hex::encode(hmac.finalize().into_bytes());
170				request.headers_mut().insert("Signature", signature.parse().unwrap());
171
172				return Ok(request);
173			}
174		}
175		Ok(builder.build().expect("Don't expect this to be reached by client. Same reasoning - fail fast for dev"))
176	}
177
178	fn handle_response(&self, status: StatusCode, headers: HeaderMap, response_body: Bytes) -> Result<Self::Successful, HandleError> {
179		if status.is_success() {
180			serde_json::from_slice(&response_body).map_err(|error| {
181				let response_str = v_utils::utils::truncate_msg(String::from_utf8_lossy(&response_body));
182				HandleError::Parse(eyre!("Failed to parse response: {error}\nResponse body: {response_str}"))
183			})
184		} else {
185			//Q: does MEXC even have this, or am I just blindly copying from Binance?
186			if status == 429 {
187				let retry_after_sec = if let Some(value) = headers.get("Retry-After") {
188					if let Ok(string) = value.to_str() {
189						if let Ok(retry_after) = u32::from_str(string) {
190							Some(retry_after)
191						} else {
192							tracing::debug!("Invalid number in Retry-After header");
193							None
194						}
195					} else {
196						tracing::debug!("Non-ASCII character in Retry-After header");
197						None
198					}
199				} else {
200					None
201				};
202				let e = match retry_after_sec {
203					Some(s) => {
204						let until = Some(Timestamp::now() + SignedDuration::from_secs(s as i64));
205						ApiError::IpTimeout { until }.into()
206					}
207					None => eyre!("Could't interpret Retry-After header").into(),
208				};
209				return Err(e);
210			}
211
212			let api_error: MexcError = match serde_json::from_slice(&response_body) {
213				Ok(parsed) => parsed,
214				Err(error) => {
215					let response_str = v_utils::utils::truncate_msg(String::from_utf8_lossy(&response_body));
216					return Err(HandleError::Parse(eyre!("Failed to parse error response: {error}\nResponse body: {response_str}")));
217				}
218			};
219			Err(ApiError::from(api_error).into())
220		}
221	}
222}
223
224// Ws stuff {{{
225/// A struct that implements [WsHandler]
226#[derive(Debug, derive_new::new)]
227pub struct MexcWsHandler {
228	options: MexcOptions,
229}
230impl WsHandler for MexcWsHandler {
231	fn config(&self) -> Result<WsConfig, UrlError> {
232		let mut config = self.options.ws_config.clone();
233		if self.options.ws_url != MexcWsUrl::None {
234			config.base_url = match self.options.testnet {
235				true => Some(self.options.ws_url.url_testnet().ok_or_else(|| UrlError::MissingTestnet(self.options.ws_url.url_mainnet()))?),
236				false => Some(self.options.ws_url.url_mainnet()),
237			}
238		}
239		config.topics = config.topics.union(&self.options.ws_topics).cloned().collect();
240		Ok(config)
241	}
242
243	fn handle_jrpc(&mut self, _jrpc: serde_json::Value) -> Result<ResponseOrContent, WsError> {
244		todo!();
245	}
246
247	fn handle_subscribe(&mut self, _topics: HashSet<Topic>) -> Result<Vec<generics::tokio_tungstenite::tungstenite::Message>, WsError> {
248		todo!()
249	}
250}
251/// Enum that represents the base url of the MEXC Ws API
252#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
253#[non_exhaustive]
254pub enum MexcWsUrl {
255	Spot,
256	Futures,
257	#[default]
258	None,
259}
260impl EndpointUrl for MexcWsUrl {
261	fn url_mainnet(&self) -> Url {
262		match self {
263			Self::Spot => Url::parse("wss://stream.mexc.com/ws").unwrap(),
264			Self::Futures => Url::parse("wss://contract.mexc.com/ws").unwrap(),
265			Self::None => Url::parse("").unwrap(),
266		}
267	}
268
269	fn url_testnet(&self) -> Option<Url> {
270		match self {
271			Self::Spot => Some(Url::parse("wss://stream-testnet.mexc.com/ws").unwrap()),
272			Self::Futures => Some(Url::parse("wss://contract-testnet.mexc.com/ws").unwrap()),
273			Self::None => None,
274		}
275	}
276}
277impl WsOption for MexcOption {
278	type WsHandler = MexcWsHandler;
279
280	fn ws_handler(options: Self::Options) -> Self::WsHandler {
281		MexcWsHandler::new(options)
282	}
283}
284//,}}}
285
286impl HandlerOptions for MexcOptions {
287	type OptionItem = MexcOption;
288
289	fn update(&mut self, option: Self::OptionItem) {
290		match option {
291			MexcOption::Default => (),
292			MexcOption::Pubkey(v) => self.pubkey = Some(v),
293			MexcOption::Secret(v) => self.secret = Some(v),
294			MexcOption::Testnet(v) => self.testnet = v,
295			MexcOption::HttpUrl(v) => self.http_url = v,
296			MexcOption::HttpAuth(v) => self.http_auth = v,
297			MexcOption::RecvWindow(v) =>
298				if v > MAX_RECV_WINDOW {
299					tracing::warn!("recvWindow is too large, overwriting with maximum value of {MAX_RECV_WINDOW:?}");
300					self.recv_window = Some(MAX_RECV_WINDOW);
301				} else {
302					self.recv_window = Some(v);
303				},
304			MexcOption::WsUrl(v) => self.ws_url = v,
305			MexcOption::WsConfig(v) => self.ws_config = v,
306			MexcOption::WsTopics(v) => self.ws_topics = v.into_iter().collect(),
307		}
308	}
309
310	fn is_authenticated(&self) -> bool {
311		self.pubkey.is_some() // some end points are satisfied with just the key, and it's really difficult to provide only a key without a secret from the clientside, so assume intent if it's missing.
312	}
313}
314
315impl<'a, R, B> HttpOption<'a, R, B> for MexcOption
316where
317	R: DeserializeOwned + 'a,
318	B: Serialize,
319{
320	type RequestHandler = MexcRequestHandler<'a, R>;
321
322	fn request_handler(options: Self::Options) -> Self::RequestHandler {
323		MexcRequestHandler::<'a, R> { options, _phantom: PhantomData }
324	}
325}
326
327impl HandlerOption for MexcOption {
328	type Options = MexcOptions;
329}