polymarket_client/secure/
secure_client.rs1use 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#[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 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 pub amount: Option<f64>,
95 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
145pub struct SecureClient {
147 public: PublicClient,
148 pub(crate) clob: AuthenticatedClob,
149 pub(crate) signer: PrivateKeySigner,
150 credentials: ApiCredentials,
151 wallet: Address,
152}
153
154#[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 #[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 #[must_use]
185 pub fn credentials(mut self, credentials: ApiCredentials) -> Self {
186 self.credentials = Some(credentials);
187 self
188 }
189
190 #[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 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}