Skip to main content

polymarket_client/secure/
secure_client.rs

1use std::ops::Deref;
2use std::str::FromStr as _;
3
4use alloy::signers::local::PrivateKeySigner;
5use alloy::signers::Signer as _;
6use chrono::{DateTime, Utc};
7use polymarket_bindings::OrderSide;
8use polymarket_client_sdk_v2::auth::state::Authenticated;
9use polymarket_client_sdk_v2::auth::Normal;
10use polymarket_client_sdk_v2::clob::types::request::{
11    CancelMarketOrderRequest, OrdersRequest, UpdateBalanceAllowanceRequest,
12};
13use polymarket_client_sdk_v2::clob::types::response::{OpenOrderResponse, PostOrderResponse};
14use polymarket_client_sdk_v2::clob::types::{OrderType, Side, SignatureType};
15use polymarket_client_sdk_v2::clob::{Client as ClobClient, Config};
16use polymarket_client_sdk_v2::types::{Address, Decimal, B256, U256};
17use polymarket_client_sdk_v2::POLYGON;
18use rust_decimal::prelude::FromPrimitive as _;
19
20use crate::environment::Environment;
21use crate::error::{user_input, UserInputError};
22use crate::public_client::PublicClient;
23use crate::secure::credentials::ApiCredentials;
24
25type AuthenticatedClob = ClobClient<Authenticated<Normal>>;
26
27/// Errors while constructing a [`SecureClient`].
28#[derive(Debug, thiserror::Error)]
29pub enum BuildSecureClientError {
30    #[error("private key is required")]
31    MissingPrivateKey,
32    #[error("invalid private key: {0}")]
33    InvalidPrivateKey(String),
34    #[error("invalid credentials: {0}")]
35    InvalidCredentials(String),
36    #[error("HTTP client error: {0}")]
37    Http(String),
38    #[error("SDK error: {0}")]
39    Sdk(String),
40}
41
42macro_rules! secure_action_error {
43    ($name:ident) => {
44        #[derive(Debug, thiserror::Error, Clone)]
45        pub enum $name {
46            #[error(transparent)]
47            UserInput(#[from] UserInputError),
48            #[error("signing or SDK error: {0}")]
49            Sdk(String),
50        }
51
52        impl $name {
53            #[must_use]
54            pub fn is_error(err: &(dyn std::error::Error + 'static)) -> bool {
55                err.downcast_ref::<Self>().is_some()
56                    || err.downcast_ref::<UserInputError>().is_some()
57            }
58        }
59    };
60}
61
62secure_action_error!(PlaceOrderError);
63secure_action_error!(FetchOrderError);
64secure_action_error!(ListOpenOrdersError);
65secure_action_error!(CancelOrderError);
66secure_action_error!(SetupTradingApprovalsError);
67
68pub type PlaceOrderResponse = OrderPlacementResponse;
69
70#[derive(Clone, Debug)]
71pub struct OrderPlacementResponse {
72    pub ok: bool,
73    pub order_id: Option<String>,
74    pub code: Option<String>,
75    pub message: Option<String>,
76}
77
78#[derive(Clone, Debug)]
79pub struct PlaceLimitOrderRequest {
80    pub token_id: String,
81    pub side: OrderSide,
82    pub price: f64,
83    pub size: f64,
84    /// Unix timestamp (seconds). Requires GTD order type.
85    pub expiration: Option<i64>,
86    pub post_only: bool,
87}
88
89#[derive(Clone, Debug)]
90pub struct PlaceMarketOrderRequest {
91    pub token_id: String,
92    pub side: OrderSide,
93    /// USDC amount for buy-side market orders.
94    pub amount: Option<f64>,
95    /// Share amount for sell-side market orders.
96    pub shares: Option<f64>,
97    pub order_type: MarketOrderType,
98}
99
100#[derive(Clone, Copy, Debug, Default)]
101pub enum MarketOrderType {
102    #[default]
103    Fak,
104    Fok,
105}
106
107#[derive(Clone, Debug, Default)]
108pub struct FetchOrderRequest {
109    pub order_id: String,
110}
111
112#[derive(Clone, Debug, Default)]
113pub struct ListOpenOrdersRequest {
114    pub market: Option<String>,
115    pub token_id: Option<String>,
116}
117
118#[derive(Clone, Debug)]
119pub struct OpenOrder {
120    pub order_id: String,
121    pub token_id: String,
122    pub side: OrderSide,
123    pub price: String,
124    pub original_size: String,
125    pub size_matched: String,
126    pub status: String,
127}
128
129#[derive(Clone, Debug)]
130pub struct CancelOrderRequest {
131    pub order_id: String,
132}
133
134#[derive(Clone, Debug, Default)]
135pub struct CancelMarketOrdersRequest {
136    pub token_id: Option<String>,
137    pub market: Option<String>,
138}
139
140#[derive(Clone, Debug)]
141pub struct CancelOrderResponse {
142    pub canceled: Vec<String>,
143}
144
145/// Authenticated Polymarket client for trading and account-scoped CLOB operations.
146pub struct SecureClient {
147    public: PublicClient,
148    pub(crate) clob: AuthenticatedClob,
149    pub(crate) signer: PrivateKeySigner,
150    credentials: ApiCredentials,
151    wallet: Address,
152}
153
154/// Builder for [`SecureClient`].
155#[derive(Clone, Debug, Default)]
156pub struct SecureClientBuilder {
157    environment: Option<Environment>,
158    private_key: Option<String>,
159    credentials: Option<ApiCredentials>,
160    funder: Option<Address>,
161    signature_type: Option<SignatureType>,
162}
163
164impl SecureClientBuilder {
165    #[must_use]
166    pub fn new() -> Self {
167        Self::default()
168    }
169
170    #[must_use]
171    pub fn environment(mut self, environment: Environment) -> Self {
172        self.environment = Some(environment);
173        self
174    }
175
176    /// Hex-encoded secp256k1 private key (`0x…`).
177    #[must_use]
178    pub fn private_key(mut self, private_key: impl Into<String>) -> Self {
179        self.private_key = Some(private_key.into());
180        self
181    }
182
183    /// Reuse stored L2 credentials from a prior session.
184    #[must_use]
185    pub fn credentials(mut self, credentials: ApiCredentials) -> Self {
186        self.credentials = Some(credentials);
187        self
188    }
189
190    /// Polymarket account wallet (funder). Required for proxy/safe/deposit wallets.
191    #[must_use]
192    pub fn wallet(mut self, wallet: Address) -> Self {
193        self.funder = Some(wallet);
194        self
195    }
196
197    #[must_use]
198    pub fn signature_type(mut self, signature_type: SignatureType) -> Self {
199        self.signature_type = Some(signature_type);
200        self
201    }
202
203    pub async fn build(self) -> Result<SecureClient, BuildSecureClientError> {
204        let environment = self.environment.unwrap_or_else(Environment::production);
205        let private_key = self
206            .private_key
207            .ok_or(BuildSecureClientError::MissingPrivateKey)?;
208        let signer = PrivateKeySigner::from_str(&private_key)
209            .map_err(|e| BuildSecureClientError::InvalidPrivateKey(e.to_string()))?
210            .with_chain_id(Some(POLYGON));
211
212        let public = PublicClient::with_environment(environment.clone())
213            .map_err(|e| BuildSecureClientError::Http(e.0))?;
214
215        let config = Config::builder().use_server_time(true).build();
216        let unauth = ClobClient::new(environment.clob, config)
217            .map_err(|e| BuildSecureClientError::Sdk(e.to_string()))?;
218
219        let credentials = if let Some(credentials) = self.credentials.clone() {
220            credentials
221        } else {
222            let sdk_creds = unauth
223                .create_or_derive_api_key(&signer, None)
224                .await
225                .map_err(|e| BuildSecureClientError::Sdk(e.to_string()))?;
226            ApiCredentials::from_sdk(&sdk_creds)
227        };
228
229        let sdk_creds = credentials
230            .to_sdk_credentials()
231            .map_err(|e| BuildSecureClientError::InvalidCredentials(e.to_string()))?;
232
233        let mut auth_builder = unauth
234            .authentication_builder(&signer)
235            .credentials(sdk_creds);
236
237        if let Some(funder) = self.funder {
238            auth_builder = auth_builder.funder(funder);
239        }
240        if let Some(signature_type) = self.signature_type {
241            auth_builder = auth_builder.signature_type(signature_type);
242        }
243
244        let clob = auth_builder
245            .authenticate()
246            .await
247            .map_err(|e| BuildSecureClientError::Sdk(e.to_string()))?;
248        let wallet = self.funder.unwrap_or_else(|| signer.address());
249
250        Ok(SecureClient {
251            public,
252            clob,
253            signer,
254            credentials,
255            wallet,
256        })
257    }
258}
259
260impl SecureClient {
261    #[must_use]
262    pub fn builder() -> SecureClientBuilder {
263        SecureClientBuilder::new()
264    }
265
266    #[must_use]
267    pub fn public(&self) -> &PublicClient {
268        &self.public
269    }
270
271    #[must_use]
272    pub fn credentials(&self) -> &ApiCredentials {
273        &self.credentials
274    }
275
276    #[must_use]
277    pub fn wallet(&self) -> Address {
278        self.wallet
279    }
280
281    #[must_use]
282    pub fn environment(&self) -> &Environment {
283        self.public.environment()
284    }
285
286    /// Syncs balance/allowance state with the CLOB. Call before first trade.
287    pub async fn setup_trading_approvals(&self) -> Result<(), SetupTradingApprovalsError> {
288        self.clob
289            .update_balance_allowance(UpdateBalanceAllowanceRequest::default())
290            .await
291            .map_err(|e| SetupTradingApprovalsError::Sdk(e.to_string()))?;
292        Ok(())
293    }
294
295    pub async fn place_limit_order(
296        &self,
297        request: PlaceLimitOrderRequest,
298    ) -> Result<PlaceOrderResponse, PlaceOrderError> {
299        validate_positive(request.price, "price")?;
300        validate_positive(request.size, "size")?;
301
302        let token_id = parse_token_id(&request.token_id)?;
303        let price = decimal_from_f64(request.price, "price")?;
304        let size = decimal_from_f64(request.size, "size")?;
305
306        let mut builder = self
307            .clob
308            .limit_order()
309            .token_id(token_id)
310            .price(price)
311            .size(size)
312            .side(map_side(request.side))
313            .post_only(request.post_only);
314
315        if let Some(expiration) = request.expiration {
316            let expiry = DateTime::<Utc>::from_timestamp(expiration, 0).ok_or_else(|| {
317                PlaceOrderError::UserInput(user_input("expiration must be a valid unix timestamp"))
318            })?;
319            builder = builder.order_type(OrderType::GTD).expiration(expiry);
320        }
321
322        let order = builder
323            .build()
324            .await
325            .map_err(|e| PlaceOrderError::Sdk(e.to_string()))?;
326        let signed = self
327            .clob
328            .sign(&self.signer, order)
329            .await
330            .map_err(|e| PlaceOrderError::Sdk(e.to_string()))?;
331        let response = self
332            .clob
333            .post_order(signed)
334            .await
335            .map_err(|e| PlaceOrderError::Sdk(e.to_string()))?;
336        Ok(map_post_order_response(response))
337    }
338
339    pub async fn place_market_order(
340        &self,
341        request: PlaceMarketOrderRequest,
342    ) -> Result<PlaceOrderResponse, PlaceOrderError> {
343        let token_id = parse_token_id(&request.token_id)?;
344        let side = map_side(request.side);
345
346        let mut builder = self.clob.market_order().token_id(token_id).side(side);
347        builder = match (request.amount, request.shares) {
348            (Some(amount), None) => {
349                validate_positive(amount, "amount")?;
350                builder.amount(
351                    polymarket_client_sdk_v2::clob::types::Amount::usdc(decimal_from_f64(
352                        amount, "amount",
353                    )?)
354                    .map_err(|e| PlaceOrderError::Sdk(e.to_string()))?,
355                )
356            }
357            (None, Some(shares)) => {
358                validate_positive(shares, "shares")?;
359                builder.amount(
360                    polymarket_client_sdk_v2::clob::types::Amount::shares(decimal_from_f64(
361                        shares, "shares",
362                    )?)
363                    .map_err(|e| PlaceOrderError::Sdk(e.to_string()))?,
364                )
365            }
366            _ => {
367                return Err(PlaceOrderError::UserInput(user_input(
368                    "provide either amount (buy) or shares (sell) for market orders",
369                )));
370            }
371        };
372
373        builder = builder.order_type(match request.order_type {
374            MarketOrderType::Fak => OrderType::FAK,
375            MarketOrderType::Fok => OrderType::FOK,
376        });
377
378        let order = builder
379            .build()
380            .await
381            .map_err(|e| PlaceOrderError::Sdk(e.to_string()))?;
382        let signed = self
383            .clob
384            .sign(&self.signer, order)
385            .await
386            .map_err(|e| PlaceOrderError::Sdk(e.to_string()))?;
387        let response = self
388            .clob
389            .post_order(signed)
390            .await
391            .map_err(|e| PlaceOrderError::Sdk(e.to_string()))?;
392        Ok(map_post_order_response(response))
393    }
394
395    pub async fn fetch_order(
396        &self,
397        request: FetchOrderRequest,
398    ) -> Result<OpenOrder, FetchOrderError> {
399        if request.order_id.trim().is_empty() {
400            return Err(FetchOrderError::UserInput(user_input(
401                "order_id cannot be empty",
402            )));
403        }
404        let order = self
405            .clob
406            .order(&request.order_id)
407            .await
408            .map_err(|e| FetchOrderError::Sdk(e.to_string()))?;
409        Ok(map_open_order(order))
410    }
411
412    pub async fn list_open_orders(
413        &self,
414        request: ListOpenOrdersRequest,
415    ) -> Result<Vec<OpenOrder>, ListOpenOrdersError> {
416        let mut req = OrdersRequest::default();
417        if let Some(market) = request.market {
418            req.market = Some(parse_market_id(&market).map_err(ListOpenOrdersError::from)?);
419        }
420        if let Some(token_id) = request.token_id {
421            req.asset_id = Some(parse_token_id(&token_id).map_err(ListOpenOrdersError::from)?);
422        }
423
424        let mut cursor = None;
425        let mut all = Vec::new();
426        loop {
427            let page = self
428                .clob
429                .orders(&req, cursor.clone())
430                .await
431                .map_err(|e| ListOpenOrdersError::Sdk(e.to_string()))?;
432            all.extend(page.data.into_iter().map(map_open_order));
433            if page.next_cursor.is_empty() || page.next_cursor == "LTE=" {
434                break;
435            }
436            cursor = Some(page.next_cursor);
437        }
438        Ok(all)
439    }
440
441    pub async fn cancel_order(
442        &self,
443        request: CancelOrderRequest,
444    ) -> Result<CancelOrderResponse, CancelOrderError> {
445        let response = self
446            .clob
447            .cancel_order(&request.order_id)
448            .await
449            .map_err(|e| CancelOrderError::Sdk(e.to_string()))?;
450        Ok(CancelOrderResponse {
451            canceled: response.canceled,
452        })
453    }
454
455    pub async fn cancel_market_orders(
456        &self,
457        request: CancelMarketOrdersRequest,
458    ) -> Result<CancelOrderResponse, CancelOrderError> {
459        let mut req = CancelMarketOrderRequest::default();
460        if let Some(token_id) = request.token_id {
461            req.asset_id = Some(parse_token_id(&token_id).map_err(CancelOrderError::from)?);
462        }
463        if let Some(market) = request.market {
464            req.market = Some(parse_market_id(&market).map_err(CancelOrderError::from)?);
465        }
466        let response = self
467            .clob
468            .cancel_market_orders(&req)
469            .await
470            .map_err(|e| CancelOrderError::Sdk(e.to_string()))?;
471        Ok(CancelOrderResponse {
472            canceled: response.canceled,
473        })
474    }
475}
476
477impl Deref for SecureClient {
478    type Target = PublicClient;
479
480    fn deref(&self) -> &Self::Target {
481        &self.public
482    }
483}
484
485fn map_side(side: OrderSide) -> Side {
486    match side {
487        OrderSide::Buy => Side::Buy,
488        OrderSide::Sell => Side::Sell,
489    }
490}
491
492fn parse_token_id(token_id: &str) -> Result<U256, UserInputError> {
493    U256::from_str(token_id).map_err(|e| user_input(format!("invalid token_id: {e}")))
494}
495
496fn parse_market_id(market: &str) -> Result<B256, UserInputError> {
497    B256::from_str(market).map_err(|e| user_input(format!("invalid market id: {e}")))
498}
499
500fn decimal_from_f64(value: f64, field: &str) -> Result<Decimal, UserInputError> {
501    Decimal::from_f64(value).ok_or_else(|| user_input(format!("invalid {field}: {value}")))
502}
503
504fn validate_positive(value: f64, field: &str) -> Result<(), UserInputError> {
505    if value <= 0.0 || !value.is_finite() {
506        return Err(user_input(format!("{field} must be a positive number")));
507    }
508    Ok(())
509}
510
511fn map_post_order_response(response: PostOrderResponse) -> PlaceOrderResponse {
512    PlaceOrderResponse {
513        ok: response.success,
514        order_id: if response.order_id.is_empty() {
515            None
516        } else {
517            Some(response.order_id)
518        },
519        code: if response.success {
520            None
521        } else {
522            Some("ORDER_REJECTED".into())
523        },
524        message: response.error_msg,
525    }
526}
527
528fn map_open_order(order: OpenOrderResponse) -> OpenOrder {
529    OpenOrder {
530        order_id: order.id,
531        token_id: order.asset_id.to_string(),
532        side: match order.side {
533            Side::Sell => OrderSide::Sell,
534            _ => OrderSide::Buy,
535        },
536        price: order.price.to_string(),
537        original_size: order.original_size.to_string(),
538        size_matched: order.size_matched.to_string(),
539        status: format!("{:?}", order.status),
540    }
541}