Skip to main content

polymarket_client_sdk/
lib.rs

1#![cfg_attr(doc, doc = include_str!("../README.md"))]
2
3pub mod auth;
4#[cfg(feature = "bridge")]
5pub mod bridge;
6#[cfg(feature = "clob")]
7pub mod clob;
8#[cfg(feature = "ctf")]
9pub mod ctf;
10#[cfg(feature = "data")]
11pub mod data;
12pub mod error;
13#[cfg(feature = "gamma")]
14pub mod gamma;
15#[cfg(feature = "rtds")]
16pub mod rtds;
17pub(crate) mod serde_helpers;
18pub mod types;
19#[cfg(any(feature = "ws", feature = "rtds"))]
20pub mod ws;
21
22use std::fmt::Write as _;
23
24use alloy::primitives::ChainId;
25use alloy::primitives::{B256, b256, keccak256};
26use phf::phf_map;
27#[cfg(any(
28    feature = "bridge",
29    feature = "clob",
30    feature = "data",
31    feature = "gamma"
32))]
33use reqwest::{Request, StatusCode, header::HeaderMap};
34use serde::Serialize;
35#[cfg(any(
36    feature = "bridge",
37    feature = "clob",
38    feature = "data",
39    feature = "gamma"
40))]
41use serde::de::DeserializeOwned;
42
43use crate::error::Error;
44use crate::types::{Address, address};
45
46pub type Result<T> = std::result::Result<T, Error>;
47
48/// [`ChainId`] for Polygon mainnet
49pub const POLYGON: ChainId = 137;
50
51/// [`ChainId`] for Polygon testnet <https://polygon.technology/blog/introducing-the-amoy-testnet-for-polygon-pos>
52pub const AMOY: ChainId = 80002;
53
54pub const PRIVATE_KEY_VAR: &str = "POLYMARKET_PRIVATE_KEY";
55
56/// Timestamp in seconds since [`std::time::UNIX_EPOCH`]
57pub(crate) type Timestamp = i64;
58
59static CONFIG: phf::Map<ChainId, ContractConfig> = phf_map! {
60    137_u64 => ContractConfig {
61        exchange: address!("0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"),
62        collateral: address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"),
63        conditional_tokens: address!("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"),
64        neg_risk_adapter: None,
65    },
66    80002_u64 => ContractConfig {
67        exchange: address!("0xdFE02Eb6733538f8Ea35D585af8DE5958AD99E40"),
68        collateral: address!("0x9c4e1703476e875070ee25b56a58b008cfb8fa78"),
69        conditional_tokens: address!("0x69308FB512518e39F9b16112fA8d994F4e2Bf8bB"),
70        neg_risk_adapter: None,
71    },
72};
73
74static NEG_RISK_CONFIG: phf::Map<ChainId, ContractConfig> = phf_map! {
75    137_u64 => ContractConfig {
76        exchange: address!("0xC5d563A36AE78145C45a50134d48A1215220f80a"),
77        collateral: address!("0x2791bca1f2de4661ed88a30c99a7a9449aa84174"),
78        conditional_tokens: address!("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"),
79        neg_risk_adapter: Some(address!("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296")),
80    },
81    80002_u64 => ContractConfig {
82        exchange: address!("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"),
83        collateral: address!("0x9c4e1703476e875070ee25b56a58b008cfb8fa78"),
84        conditional_tokens: address!("0x69308FB512518e39F9b16112fA8d994F4e2Bf8bB"),
85        neg_risk_adapter: Some(address!("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296")),
86    },
87};
88
89// Wallet contract configurations for CREATE2 address derivation
90// Source: https://github.com/Polymarket/builder-relayer-client
91static WALLET_CONFIG: phf::Map<ChainId, WalletContractConfig> = phf_map! {
92    137_u64 => WalletContractConfig {
93        proxy_factory: Some(address!("0xaB45c5A4B0c941a2F231C04C3f49182e1A254052")),
94        safe_factory: address!("0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"),
95    },
96    80002_u64 => WalletContractConfig {
97        // Proxy factory unsupported on Amoy testnet
98        proxy_factory: None,
99        safe_factory: address!("0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"),
100    },
101};
102
103/// Init code hash for Polymarket Proxy wallets (EIP-1167 minimal proxy)
104const PROXY_INIT_CODE_HASH: B256 =
105    b256!("0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8af6330a00b");
106
107/// Init code hash for Gnosis Safe wallets
108const SAFE_INIT_CODE_HASH: B256 =
109    b256!("0x2bce2127ff07fb632d16c8347c4ebf501f4841168bed00d9e6ef715ddb6fcecf");
110
111/// Helper struct to group the relevant deployed contract addresses
112#[non_exhaustive]
113#[derive(Debug)]
114pub struct ContractConfig {
115    pub exchange: Address,
116    pub collateral: Address,
117    pub conditional_tokens: Address,
118    /// The Neg Risk Adapter contract address. Only present for neg-risk market configs.
119    /// Users must approve this contract for token transfers to trade in neg-risk markets.
120    pub neg_risk_adapter: Option<Address>,
121}
122
123/// Wallet contract configuration for CREATE2 address derivation
124#[non_exhaustive]
125#[derive(Debug)]
126pub struct WalletContractConfig {
127    /// Factory contract for Polymarket Proxy wallets (Magic/email wallets).
128    /// Not available on all networks (e.g., Amoy testnet).
129    pub proxy_factory: Option<Address>,
130    /// Factory contract for Gnosis Safe wallets.
131    pub safe_factory: Address,
132}
133
134/// Given a `chain_id` and `is_neg_risk`, return the relevant [`ContractConfig`]
135#[must_use]
136pub fn contract_config(chain_id: ChainId, is_neg_risk: bool) -> Option<&'static ContractConfig> {
137    if is_neg_risk {
138        NEG_RISK_CONFIG.get(&chain_id)
139    } else {
140        CONFIG.get(&chain_id)
141    }
142}
143
144/// Returns the wallet contract configuration for the given chain ID.
145#[must_use]
146pub fn wallet_contract_config(chain_id: ChainId) -> Option<&'static WalletContractConfig> {
147    WALLET_CONFIG.get(&chain_id)
148}
149
150/// Derives the Polymarket Proxy wallet address for an EOA using CREATE2.
151///
152/// This is the deterministic address of the EIP-1167 minimal proxy wallet
153/// that Polymarket deploys for Magic/email wallet users.
154///
155/// # Arguments
156/// * `eoa_address` - The externally owned account (EOA) address
157/// * `chain_id` - The chain ID (e.g., 137 for Polygon mainnet)
158///
159/// # Returns
160/// * `Some(Address)` - The derived proxy wallet address
161/// * `None` - If the chain doesn't support proxy wallets or config is missing
162#[must_use]
163pub fn derive_proxy_wallet(eoa_address: Address, chain_id: ChainId) -> Option<Address> {
164    let config = wallet_contract_config(chain_id)?;
165    let factory = config.proxy_factory?;
166
167    // Salt is keccak256(encodePacked(address)) - address is 20 bytes, no padding
168    let salt = keccak256(eoa_address);
169
170    Some(factory.create2(salt, PROXY_INIT_CODE_HASH))
171}
172
173/// Derives the Gnosis Safe wallet address for an EOA using CREATE2.
174///
175/// This is the deterministic address of the 1-of-1 Gnosis Safe multisig
176/// that Polymarket deploys for browser wallet users.
177///
178/// # Arguments
179/// * `eoa_address` - The externally owned account (EOA) address
180/// * `chain_id` - The chain ID (e.g., 137 for Polygon mainnet)
181///
182/// # Returns
183/// * `Some(Address)` - The derived Safe wallet address
184/// * `None` - If the chain config is missing
185#[must_use]
186pub fn derive_safe_wallet(eoa_address: Address, chain_id: ChainId) -> Option<Address> {
187    let config = wallet_contract_config(chain_id)?;
188    let factory = config.safe_factory;
189
190    // Salt is keccak256(encodeAbiParameters(address)) - address padded to 32 bytes
191    // ABI encoding pads address to 32 bytes (left-padded with zeros)
192    let mut padded = [0_u8; 32];
193    padded[12..].copy_from_slice(eoa_address.as_slice());
194    let salt = keccak256(padded);
195
196    Some(factory.create2(salt, SAFE_INIT_CODE_HASH))
197}
198
199/// Trait for converting request types to URL query parameters.
200///
201/// This trait is automatically implemented for all types that implement [`Serialize`].
202/// It uses [`serde_html_form`] to serialize the struct fields into a query string.
203/// Arrays are serialized as repeated keys (`key=val1&key=val2`).
204pub trait ToQueryParams: Serialize {
205    /// Converts the request to a URL query string.
206    ///
207    /// Returns an empty string if no parameters are set, otherwise returns
208    /// a string starting with `?` followed by URL-encoded key-value pairs.
209    /// Also uses an optional cursor as a parameter, if provided.
210    fn query_params(&self, next_cursor: Option<&str>) -> String {
211        let mut params = serde_html_form::to_string(self)
212            .inspect_err(|e| {
213                #[cfg(feature = "tracing")]
214                tracing::error!("Unable to convert to URL-encoded string {e:?}");
215                #[cfg(not(feature = "tracing"))]
216                let _: &serde_html_form::ser::Error = e;
217            })
218            .unwrap_or_default();
219
220        if let Some(cursor) = next_cursor {
221            if !params.is_empty() {
222                params.push('&');
223            }
224            let _ = write!(params, "next_cursor={cursor}");
225        }
226
227        if params.is_empty() {
228            String::new()
229        } else {
230            format!("?{params}")
231        }
232    }
233}
234
235impl<T: Serialize> ToQueryParams for T {}
236
237#[cfg(any(
238    feature = "bridge",
239    feature = "clob",
240    feature = "data",
241    feature = "gamma"
242))]
243#[cfg_attr(
244    feature = "tracing",
245    tracing::instrument(
246        level = "debug",
247        skip(client, request, headers),
248        fields(
249            method = %request.method(),
250            path = request.url().path(),
251            status_code
252        )
253    )
254)]
255async fn request<Response: DeserializeOwned>(
256    client: &reqwest::Client,
257    mut request: Request,
258    headers: Option<HeaderMap>,
259) -> Result<Response> {
260    let method = request.method().clone();
261    let path = request.url().path().to_owned();
262
263    if let Some(h) = headers {
264        *request.headers_mut() = h;
265    }
266
267    let response = client.execute(request).await?;
268    let status_code = response.status();
269
270    #[cfg(feature = "tracing")]
271    tracing::Span::current().record("status_code", status_code.as_u16());
272
273    if !status_code.is_success() {
274        let message = response.text().await.unwrap_or_default();
275
276        #[cfg(feature = "tracing")]
277        tracing::warn!(
278            status = %status_code,
279            method = %method,
280            path = %path,
281            message = %message,
282            "API request failed"
283        );
284
285        return Err(Error::status(status_code, method, path, message));
286    }
287
288    let json_value = response.json::<serde_json::Value>().await?;
289    let response_data: Option<Response> = serde_helpers::deserialize_with_warnings(json_value)?;
290
291    if let Some(response) = response_data {
292        Ok(response)
293    } else {
294        #[cfg(feature = "tracing")]
295        tracing::warn!(method = %method, path = %path, "API resource not found");
296        Err(Error::status(
297            StatusCode::NOT_FOUND,
298            method,
299            path,
300            "Unable to find requested resource",
301        ))
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn config_contains_80002() {
311        let cfg = contract_config(AMOY, false).expect("missing config");
312        assert_eq!(
313            cfg.exchange,
314            address!("0xdFE02Eb6733538f8Ea35D585af8DE5958AD99E40")
315        );
316    }
317
318    #[test]
319    fn config_contains_80002_neg() {
320        let cfg = contract_config(AMOY, true).expect("missing config");
321        assert_eq!(
322            cfg.exchange,
323            address!("0xd91e80cf2e7be2e162c6513ced06f1dd0da35296")
324        );
325    }
326
327    #[test]
328    fn wallet_contract_config_polygon() {
329        let cfg = wallet_contract_config(POLYGON).expect("missing config");
330        assert_eq!(
331            cfg.proxy_factory,
332            Some(address!("0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"))
333        );
334        assert_eq!(
335            cfg.safe_factory,
336            address!("0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b")
337        );
338    }
339
340    #[test]
341    fn wallet_contract_config_amoy() {
342        let cfg = wallet_contract_config(AMOY).expect("missing config");
343        // Proxy factory not supported on Amoy
344        assert_eq!(cfg.proxy_factory, None);
345        assert_eq!(
346            cfg.safe_factory,
347            address!("0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b")
348        );
349    }
350
351    #[test]
352    fn derive_safe_wallet_polygon() {
353        // Test address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (Foundry/Anvil test key)
354        let eoa = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
355        let safe_addr = derive_safe_wallet(eoa, POLYGON).expect("derivation failed");
356
357        // This is the deterministic Safe address for this EOA on Polygon
358        assert_eq!(
359            safe_addr,
360            address!("0xd93b25Cb943D14d0d34FBAf01fc93a0F8b5f6e47")
361        );
362    }
363
364    #[test]
365    fn derive_proxy_wallet_polygon() {
366        // Test address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (Foundry/Anvil test key)
367        let eoa = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
368        let proxy_addr = derive_proxy_wallet(eoa, POLYGON).expect("derivation failed");
369
370        // This is the deterministic Proxy address for this EOA on Polygon
371        assert_eq!(
372            proxy_addr,
373            address!("0x365f0cA36ae1F641E02Fe3b7743673DA42A13a70")
374        );
375    }
376
377    #[test]
378    fn derive_proxy_wallet_amoy_not_supported() {
379        let eoa = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
380        // Proxy wallet derivation should fail on Amoy (no proxy factory)
381        assert!(derive_proxy_wallet(eoa, AMOY).is_none());
382    }
383
384    #[test]
385    fn derive_safe_wallet_amoy() {
386        let eoa = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
387        // Safe wallet derivation should work on Amoy
388        let safe_addr = derive_safe_wallet(eoa, AMOY).expect("derivation failed");
389
390        // Same Safe factory on both networks, so same derived address
391        assert_eq!(
392            safe_addr,
393            address!("0xd93b25Cb943D14d0d34FBAf01fc93a0F8b5f6e47")
394        );
395    }
396
397    #[test]
398    fn derive_wallet_unsupported_chain() {
399        let eoa = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
400        // Unsupported chain should return None
401        assert!(derive_proxy_wallet(eoa, 1).is_none());
402        assert!(derive_safe_wallet(eoa, 1).is_none());
403    }
404}