1use std::{collections::HashMap, result::Result as StdResult, str::from_utf8};
19
20use alloy_primitives::{Address, U256};
21use nautilus_core::time::get_atomic_clock_realtime;
22use nautilus_network::{
23 http::{
24 HttpClient, HttpClientError, HttpRedirectPolicy, HttpResponse, Method,
25 create_standard_nautilus_headers,
26 },
27 websocket::proxy::ProxyUrl,
28};
29use serde::{Deserialize, Serialize};
30
31use crate::{
32 common::{
33 credential::{Credential, RelayerApiKey},
34 urls::relayer_http_url,
35 },
36 http::error::{Error, Result, decode_response},
37 signing::eip712::{DEPOSIT_WALLET_FACTORY, DepositWalletCall},
38};
39
40const PATH_NONCE: &str = "/v1/account/transactions/params";
41const PATH_SUBMIT: &str = "/submit";
42const PATH_TRANSACTION: &str = "/v1/account/transactions";
43
44#[derive(Clone, Debug, PartialEq, Eq)]
46pub enum RelayerTransactionState {
47 New,
49 Confirmed,
51 Failed,
53 Invalid,
55 Other(String),
57}
58
59impl RelayerTransactionState {
60 #[must_use]
61 pub fn as_str(&self) -> &str {
62 match self {
63 Self::New => "STATE_NEW",
64 Self::Confirmed => "STATE_CONFIRMED",
65 Self::Failed => "STATE_FAILED",
66 Self::Invalid => "STATE_INVALID",
67 Self::Other(value) => value.as_str(),
68 }
69 }
70
71 #[must_use]
72 pub fn is_terminal(&self) -> bool {
73 matches!(self, Self::Confirmed | Self::Failed | Self::Invalid)
74 }
75
76 fn from_wire(value: &str) -> Self {
77 match value {
78 "STATE_NEW" => Self::New,
79 "STATE_CONFIRMED" => Self::Confirmed,
80 "STATE_FAILED" => Self::Failed,
81 "STATE_INVALID" => Self::Invalid,
82 other => Self::Other(other.to_string()),
83 }
84 }
85}
86
87#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct RelayerTransaction {
90 pub transaction_id: Option<String>,
92 pub transaction_hash: Option<String>,
94 pub state: RelayerTransactionState,
96 pub error_msg: Option<String>,
98}
99
100#[derive(Debug, Clone)]
102pub struct PolymarketRelayerHttpClient {
103 client: HttpClient,
104 base_url: String,
105 credential: RelayerAuthentication,
106}
107
108impl PolymarketRelayerHttpClient {
109 pub fn new(
115 credential: RelayerApiKey,
116 base_url: Option<String>,
117 timeout_secs: u64,
118 ) -> StdResult<Self, HttpClientError> {
119 Self::new_with_proxy(credential, base_url, timeout_secs, None)
120 }
121
122 pub fn new_with_proxy(
128 credential: RelayerApiKey,
129 base_url: Option<String>,
130 timeout_secs: u64,
131 proxy_url: Option<ProxyUrl>,
132 ) -> StdResult<Self, HttpClientError> {
133 Self::with_auth(
134 RelayerAuthentication::Relayer(credential),
135 base_url,
136 timeout_secs,
137 proxy_url,
138 )
139 }
140
141 pub fn new_with_builder(
147 credential: Credential,
148 base_url: Option<String>,
149 timeout_secs: u64,
150 proxy_url: Option<ProxyUrl>,
151 ) -> StdResult<Self, HttpClientError> {
152 Self::with_auth(
153 RelayerAuthentication::Builder(Box::new(credential)),
154 base_url,
155 timeout_secs,
156 proxy_url,
157 )
158 }
159
160 pub(crate) async fn post_session<T: serde::de::DeserializeOwned>(
161 &self,
162 path: &str,
163 body: &str,
164 idempotency_key: &str,
165 ) -> Result<T> {
166 let mut headers = self.auth_headers("POST", path, body);
167 headers.insert("Idempotency-Key".into(), idempotency_key.into());
168 let response = self
169 .client
170 .request(
171 Method::POST,
172 self.url(path),
173 None,
174 Some(headers),
175 Some(body.as_bytes().to_vec()),
176 None,
177 None,
178 )
179 .await
180 .map_err(Error::from_http_client)?;
181 decode_response(&response)
182 }
183
184 fn with_auth(
185 credential: RelayerAuthentication,
186 base_url: Option<String>,
187 timeout_secs: u64,
188 proxy_url: Option<ProxyUrl>,
189 ) -> StdResult<Self, HttpClientError> {
190 Ok(Self {
191 client: HttpClient::builder()
192 .headers(Self::default_headers())
193 .redirect_policy(HttpRedirectPolicy::Reject)
194 .timeout_secs(timeout_secs)
195 .maybe_proxy_url(proxy_url.map(|url| url.expose().to_string()))
196 .build()?,
197 base_url: base_url
198 .unwrap_or_else(|| relayer_http_url().to_string())
199 .trim_end_matches('/')
200 .to_string(),
201 credential,
202 })
203 }
204
205 fn default_headers() -> HashMap<String, String> {
206 let mut headers: HashMap<String, String> =
207 create_standard_nautilus_headers().into_iter().collect();
208 headers.insert("Content-Type".to_string(), "application/json".to_string());
209 headers
210 }
211
212 fn url(&self, path: &str) -> String {
213 format!("{}{path}", self.base_url)
214 }
215
216 fn auth_headers(&self, method: &str, path: &str, body: &str) -> HashMap<String, String> {
217 match &self.credential {
218 RelayerAuthentication::Relayer(credential) => HashMap::from([
219 ("RELAYER_API_KEY".into(), credential.key().to_string()),
220 (
221 "RELAYER_API_KEY_ADDRESS".into(),
222 credential.address().to_string(),
223 ),
224 ]),
225 RelayerAuthentication::Builder(credential) => {
226 let timestamp = (get_atomic_clock_realtime().get_time_ns().as_u64()
227 / 1_000_000_000)
228 .to_string();
229 HashMap::from([
230 (
231 "POLY_BUILDER_API_KEY".into(),
232 credential.api_key_str().to_string(),
233 ),
234 (
235 "POLY_BUILDER_PASSPHRASE".into(),
236 credential.passphrase().to_string(),
237 ),
238 (
239 "POLY_BUILDER_SIGNATURE".into(),
240 credential.sign(×tamp, method, path, body),
241 ),
242 ("POLY_BUILDER_TIMESTAMP".into(), timestamp),
243 ])
244 }
245 }
246 }
247
248 pub async fn get_wallet_nonce(&self, signer: Address) -> Result<U256> {
254 let address = format!("{signer:#x}");
255 let params = [("address", address.as_str()), ("type", "WALLET")];
256 let response: RelayerNonceResponse = self.send_get(PATH_NONCE, Some(¶ms)).await?;
257 parse_u256(&response.nonce, "nonce")
258 }
259
260 pub async fn submit_wallet_batch(
270 &self,
271 request: RelayerWalletSubmit<'_>,
272 ) -> Result<RelayerTransaction> {
273 if request.calls.is_empty() {
274 return Err(Error::bad_request(
275 "Deposit Wallet batch must contain at least one call",
276 ));
277 }
278
279 let body = RelayerSubmitRequest {
280 tx_type: "WALLET",
281 from: format!("{:#x}", request.signer),
282 to: format!("{DEPOSIT_WALLET_FACTORY:#x}"),
283 nonce: request.nonce.to_string(),
284 signature: request.signature.to_string(),
285 metadata: request.metadata.to_string(),
286 deposit_wallet_params: DepositWalletParams {
287 deposit_wallet: format!("{:#x}", request.deposit_wallet),
288 deadline: request.deadline.to_string(),
289 calls: request.calls.iter().map(WireCall::from).collect(),
290 },
291 };
292
293 let body = serde_json::to_string(&body)?;
294 let headers = Some(self.auth_headers("POST", PATH_SUBMIT, &body));
295 let body_bytes = body.into_bytes();
296 let url = self.url(PATH_SUBMIT);
297 let response = self
298 .client
299 .request(
300 Method::POST,
301 url,
302 None,
303 headers,
304 Some(body_bytes),
305 None,
306 None,
307 )
308 .await
309 .map_err(Error::from_http_client)?;
310 decode_relayer_transaction(&response)
311 }
312
313 pub async fn get_transaction(&self, transaction_id: &str) -> Result<RelayerTransaction> {
320 if transaction_id.trim().is_empty() {
321 return Err(Error::bad_request("transaction_id must not be empty"));
322 }
323
324 let path = format!("{PATH_TRANSACTION}/{transaction_id}");
325 let url = self.url(&path);
326 let response = self
327 .client
328 .request_with_params(
329 Method::GET,
330 url,
331 None::<&[(&str, &str); 0]>,
332 Some(self.auth_headers("GET", &path, "")),
333 None,
334 None,
335 None,
336 )
337 .await
338 .map_err(Error::from_http_client)?;
339 let transaction = decode_relayer_transaction(&response)?;
340 if transaction.transaction_id.as_deref() != Some(transaction_id) {
341 return Err(Error::decode(format!(
342 "Relayer response did not match transaction {transaction_id}; transaction outcome is unknown"
343 )));
344 }
345
346 Ok(transaction)
347 }
348
349 async fn send_get<P: Serialize, T: serde::de::DeserializeOwned>(
350 &self,
351 path: &str,
352 params: Option<&P>,
353 ) -> Result<T> {
354 let url = self.url(path);
355 let response = self
356 .client
357 .request_with_params(
358 Method::GET,
359 url,
360 params,
361 Some(self.auth_headers("GET", path, "")),
362 None,
363 None,
364 None,
365 )
366 .await
367 .map_err(Error::from_http_client)?;
368 decode_response(&response)
369 }
370}
371
372#[derive(Debug, Clone)]
373enum RelayerAuthentication {
374 Relayer(RelayerApiKey),
375 Builder(Box<Credential>),
376}
377
378#[derive(Clone, Debug)]
380pub struct RelayerWalletSubmit<'a> {
381 pub signer: Address,
383 pub deposit_wallet: Address,
385 pub nonce: U256,
387 pub deadline: U256,
389 pub signature: &'a str,
391 pub metadata: &'a str,
393 pub calls: &'a [DepositWalletCall],
395}
396
397#[derive(Deserialize)]
398struct RelayerNonceResponse {
399 nonce: String,
400}
401
402#[derive(Serialize)]
403#[serde(rename_all = "camelCase")]
404struct RelayerSubmitRequest {
405 #[serde(rename = "type")]
406 tx_type: &'static str,
407 from: String,
408 to: String,
409 nonce: String,
410 signature: String,
411 metadata: String,
412 deposit_wallet_params: DepositWalletParams,
413}
414
415#[derive(Serialize)]
416#[serde(rename_all = "camelCase")]
417struct DepositWalletParams {
418 deposit_wallet: String,
419 deadline: String,
420 calls: Vec<WireCall>,
421}
422
423#[derive(Serialize)]
424struct WireCall {
425 target: String,
426 value: String,
427 data: String,
428}
429
430impl From<&DepositWalletCall> for WireCall {
431 fn from(call: &DepositWalletCall) -> Self {
432 Self {
433 target: format!("{:#x}", call.target),
434 value: call.value.to_string(),
435 data: format!("0x{}", alloy_primitives::hex::encode(&call.data)),
436 }
437 }
438}
439
440#[derive(Deserialize)]
441struct RelayerTransactionWire {
442 #[serde(alias = "transactionID", alias = "transaction_id")]
443 transaction_id: Option<String>,
444 #[serde(alias = "transactionHash", alias = "transaction_hash")]
445 transaction_hash: Option<String>,
446 state: String,
447 #[serde(alias = "errorMsg", alias = "error_msg")]
448 error_msg: Option<String>,
449}
450
451impl From<RelayerTransactionWire> for RelayerTransaction {
452 fn from(wire: RelayerTransactionWire) -> Self {
453 Self {
454 transaction_id: empty_to_none(wire.transaction_id),
455 transaction_hash: empty_to_none(wire.transaction_hash),
456 state: RelayerTransactionState::from_wire(&wire.state),
457 error_msg: empty_to_none(wire.error_msg),
458 }
459 }
460}
461
462fn empty_to_none(value: Option<String>) -> Option<String> {
463 value.and_then(|value| {
464 let trimmed = value.trim();
465 if trimmed.is_empty() {
466 None
467 } else {
468 Some(trimmed.to_string())
469 }
470 })
471}
472
473fn parse_u256(value: &str, field: &str) -> Result<U256> {
474 let trimmed = value.trim();
475 if trimmed.is_empty() {
476 return Err(Error::decode(format!("{field} was empty")));
477 }
478
479 if let Some(hex) = trimmed.strip_prefix("0x") {
480 return U256::from_str_radix(hex, 16)
481 .map_err(|e| Error::decode(format!("Invalid {field}: {e}")));
482 }
483
484 trimmed
485 .parse()
486 .map_err(|e| Error::decode(format!("Invalid {field}: {e}")))
487}
488
489fn decode_relayer_transaction(response: &HttpResponse) -> Result<RelayerTransaction> {
490 if !response.status.is_success() {
491 return Err(Error::from_status_code(
492 response.status.as_u16(),
493 &response.body,
494 ));
495 }
496
497 let body = from_utf8(&response.body)
498 .map_err(|e| Error::decode(format!("UTF-8 error: {e}")))?
499 .trim();
500
501 if body.is_empty() || body == "null" {
502 return Err(Error::decode(
503 "Relayer submit response was empty; transaction outcome is unknown",
504 ));
505 }
506
507 if let Ok(wires) = serde_json::from_str::<Vec<RelayerTransactionWire>>(body) {
508 let wire = wires.into_iter().next().ok_or_else(|| {
509 Error::decode(
510 "Relayer transaction response was an empty array; transaction outcome is unknown",
511 )
512 })?;
513
514 return Ok(wire.into());
515 }
516
517 let wire = serde_json::from_str::<RelayerTransactionWire>(body)?;
518 Ok(wire.into())
519}
520
521#[cfg(test)]
522mod tests {
523 use rstest::rstest;
524 use serde_json::json;
525
526 use super::*;
527
528 #[rstest]
529 fn test_relayer_transaction_state_terminal() {
530 assert!(!RelayerTransactionState::New.is_terminal());
531 assert!(RelayerTransactionState::Confirmed.is_terminal());
532 assert!(RelayerTransactionState::Failed.is_terminal());
533 assert!(RelayerTransactionState::Invalid.is_terminal());
534 assert!(!RelayerTransactionState::Other("STATE_EXECUTED".into()).is_terminal());
535 }
536
537 #[rstest]
538 fn test_decode_submit_response_aliases() {
539 let body = serde_json::to_vec(&json!({
540 "transactionID": "tx-1",
541 "state": "STATE_NEW"
542 }))
543 .unwrap();
544 let wire: RelayerTransactionWire = serde_json::from_slice(&body).unwrap();
545 let tx = RelayerTransaction::from(wire);
546 assert_eq!(tx.transaction_id.as_deref(), Some("tx-1"));
547 assert_eq!(tx.state, RelayerTransactionState::New);
548 assert!(tx.transaction_hash.is_none());
549 }
550
551 #[rstest]
552 fn test_decode_poll_response_snake_case() {
553 let body = serde_json::to_vec(&json!({
554 "transaction_id": "tx-2",
555 "transaction_hash": "0xabc",
556 "state": "STATE_CONFIRMED",
557 "error_msg": null
558 }))
559 .unwrap();
560 let wire: RelayerTransactionWire = serde_json::from_slice(&body).unwrap();
561 let tx = RelayerTransaction::from(wire);
562 assert_eq!(tx.transaction_id.as_deref(), Some("tx-2"));
563 assert_eq!(tx.transaction_hash.as_deref(), Some("0xabc"));
564 assert_eq!(tx.state, RelayerTransactionState::Confirmed);
565 assert!(tx.error_msg.is_none());
566 }
567
568 #[rstest]
569 fn test_parse_u256_decimal_and_hex() {
570 assert_eq!(parse_u256("12", "nonce").unwrap(), U256::from(12u64));
571 assert_eq!(parse_u256("0x0a", "nonce").unwrap(), U256::from(10u64));
572 assert!(parse_u256("", "nonce").is_err());
573 }
574}