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