Skip to main content

nautilus_polymarket/http/
relayer.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! HTTP client for the Polymarket Relayer v2 API.
17
18use 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/// Relayer transaction lifecycle state.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub enum RelayerTransactionState {
47    /// Transaction accepted and not yet terminal.
48    New,
49    /// Transaction mined successfully.
50    Confirmed,
51    /// Transaction failed on chain or in the relayer.
52    Failed,
53    /// Transaction was marked invalid.
54    Invalid,
55    /// A non-terminal or unrecognized relayer state.
56    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/// Response from Relayer submit or transaction polling.
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct RelayerTransaction {
90    /// Relayer transaction identifier when the venue supplied one.
91    pub transaction_id: Option<String>,
92    /// On-chain transaction hash when the venue supplied one.
93    pub transaction_hash: Option<String>,
94    /// Relayer lifecycle state.
95    pub state: RelayerTransactionState,
96    /// Relayer error detail when present.
97    pub error_msg: Option<String>,
98}
99
100/// HTTP client for Relayer nonce, submit, and transaction polling.
101#[derive(Debug, Clone)]
102pub struct PolymarketRelayerHttpClient {
103    client: HttpClient,
104    base_url: String,
105    credential: RelayerAuthentication,
106}
107
108impl PolymarketRelayerHttpClient {
109    /// Creates a Relayer HTTP client.
110    ///
111    /// # Errors
112    ///
113    /// Returns an error if the HTTP client cannot be created.
114    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    /// Creates a Relayer HTTP client with an optional proxy.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if the HTTP client cannot be created.
127    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    /// Creates a Relayer client authenticated by a Builder HMAC credential.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if the HTTP client cannot be created.
146    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(&timestamp, method, path, body),
241                    ),
242                    ("POLY_BUILDER_TIMESTAMP".into(), timestamp),
243                ])
244            }
245        }
246    }
247
248    /// Fetches a fresh `WALLET` nonce for `signer`.
249    ///
250    /// # Errors
251    ///
252    /// Returns an error if the Relayer rejects the request or the nonce is missing.
253    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(&params)).await?;
257        parse_u256(&response.nonce, "nonce")
258    }
259
260    /// Submits a signed Deposit Wallet batch.
261    ///
262    /// This method does not retry. A lost or timed-out submit remains an
263    /// explicit ambiguous outcome.
264    ///
265    /// # Errors
266    ///
267    /// Returns an error if the Relayer rejects the request, the HTTP client
268    /// times out, or the response cannot be decoded.
269    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    /// Polls a Relayer transaction by ID.
314    ///
315    /// # Errors
316    ///
317    /// Returns an error if the Relayer rejects the request or the body cannot
318    /// be decoded.
319    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/// Signed Deposit Wallet batch submitted to the Relayer.
379#[derive(Clone, Debug)]
380pub struct RelayerWalletSubmit<'a> {
381    /// Signer address authorizing the batch.
382    pub signer: Address,
383    /// Deposit Wallet executing the batch.
384    pub deposit_wallet: Address,
385    /// Fresh Relayer wallet nonce.
386    pub nonce: U256,
387    /// Unix-second signature deadline.
388    pub deadline: U256,
389    /// EIP-712 batch signature.
390    pub signature: &'a str,
391    /// Relayer metadata string.
392    pub metadata: &'a str,
393    /// Ordered contract calls.
394    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}