1pub mod amounts;
19pub mod calldata;
20
21mod wallet;
22
23use std::{
24 collections::HashMap,
25 sync::{Arc, LazyLock},
26 time::{Duration, Instant},
27};
28
29use alloy_primitives::{Address, U256};
30use nautilus_core::time::{AtomicTime, get_atomic_clock_realtime};
31use nautilus_network::websocket::proxy::ProxyUrl;
32use parking_lot::Mutex;
33use rust_decimal::Decimal;
34
35use self::calldata::{
36 PositionCall, encode_merge_positions, encode_redeem_positions, encode_split_position,
37};
38use crate::{
39 common::{
40 credential::{EvmPrivateKey, RelayerApiKey},
41 parse::validate_market_version,
42 },
43 http::{
44 clob::PolymarketClobPublicClient,
45 error::{Error, Result},
46 relayer::{
47 PolymarketRelayerHttpClient, RelayerTransaction, RelayerTransactionState,
48 RelayerWalletSubmit,
49 },
50 },
51 signing::eip712::{DepositWalletCall, OrderSigner, parse_address},
52};
53
54const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 60;
55const DEFAULT_DEADLINE_SECS: u64 = 1_800;
56const DEFAULT_WAIT_TIMEOUT_SECS: u64 = 120;
57const DEFAULT_POLL_INTERVAL_MS: u64 = 1_000;
58
59static WALLET_SUBMISSIONS: LazyLock<Mutex<HashMap<Address, Arc<WalletSubmissionState>>>> =
60 LazyLock::new(|| Mutex::new(HashMap::new()));
61
62#[derive(Clone, Debug, PartialEq, Eq)]
64pub enum PolymarketPositionOutcome {
65 Confirmed {
67 transaction_id: String,
69 transaction_hash: Option<String>,
71 },
72 Failed {
74 transaction_id: String,
76 transaction_hash: Option<String>,
78 error_msg: Option<String>,
80 },
81 Invalid {
83 transaction_id: String,
85 error_msg: Option<String>,
87 },
88}
89
90#[derive(Debug)]
92pub struct PolymarketPositionTransaction {
93 relayer: PolymarketRelayerHttpClient,
94 transaction_id: String,
95 wait_timeout: Duration,
96 poll_interval: Duration,
97}
98
99impl PolymarketPositionTransaction {
100 #[must_use]
102 pub fn transaction_id(&self) -> &str {
103 &self.transaction_id
104 }
105
106 pub async fn wait(self) -> Result<PolymarketPositionOutcome> {
114 let deadline = Instant::now() + self.wait_timeout;
115
116 loop {
117 match self.relayer.get_transaction(&self.transaction_id).await {
118 Ok(tx) => {
119 if tx.state.is_terminal() {
120 return outcome_from_transaction(tx);
121 }
122 }
123 Err(e) if e.is_retryable() => {
124 log::warn!(
125 "Relayer transaction {} poll failed: {e}; retrying",
126 self.transaction_id
127 );
128 }
129 Err(e) => return Err(e),
130 }
131
132 if Instant::now() >= deadline {
133 return Err(Error::exchange(format!(
134 "Relayer transaction {} wait timed out; terminal state is unknown",
135 self.transaction_id
136 )));
137 }
138
139 tokio::time::sleep(self.poll_interval).await;
140 }
141 }
142}
143
144#[derive(Debug)]
146pub struct PolymarketPositionClient {
147 signer: OrderSigner,
148 deposit_wallet: Address,
149 relayer: PolymarketRelayerHttpClient,
150 clob: PolymarketClobPublicClient,
151 clock: &'static AtomicTime,
152 deadline_secs: u64,
153 wait_timeout: Duration,
154 poll_interval: Duration,
155 wallet: wallet::WalletVerifier,
156 submission: Arc<WalletSubmissionState>,
157}
158
159impl PolymarketPositionClient {
160 pub fn new(
167 private_key: &EvmPrivateKey,
168 deposit_wallet: &str,
169 relayer_api_key: RelayerApiKey,
170 base_url_relayer: Option<String>,
171 base_url_clob: Option<String>,
172 timeout_secs: Option<u64>,
173 proxy_url: Option<ProxyUrl>,
174 ) -> Result<Self> {
175 let timeout_secs = timeout_secs.unwrap_or(DEFAULT_HTTP_TIMEOUT_SECS);
176 let signer = OrderSigner::new(private_key)?;
177 let deposit_wallet = parse_address(deposit_wallet, "deposit_wallet")?;
178 if deposit_wallet == signer.address() {
179 return Err(Error::bad_request(
180 "Deposit Wallet operations require a funder distinct from the signing address",
181 ));
182 }
183
184 let relayer = PolymarketRelayerHttpClient::new_with_proxy(
185 relayer_api_key,
186 base_url_relayer,
187 timeout_secs,
188 proxy_url.clone(),
189 )
190 .map_err(Error::from_http_client)?;
191 let wallet = wallet::WalletVerifier::new(timeout_secs, proxy_url.clone())?;
192 let clob =
193 PolymarketClobPublicClient::new_with_proxy(base_url_clob, timeout_secs, proxy_url)
194 .map_err(Error::from_http_client)?;
195
196 Ok(Self {
197 signer,
198 deposit_wallet,
199 relayer,
200 clob,
201 clock: get_atomic_clock_realtime(),
202 deadline_secs: DEFAULT_DEADLINE_SECS,
203 wait_timeout: Duration::from_secs(DEFAULT_WAIT_TIMEOUT_SECS),
204 poll_interval: Duration::from_millis(DEFAULT_POLL_INTERVAL_MS),
205 wallet,
206 submission: wallet_submission(deposit_wallet),
207 })
208 }
209
210 #[must_use]
212 pub fn with_rpc_url(mut self, rpc_url: String) -> Self {
213 self.wallet.set_rpc_url(rpc_url);
214 self
215 }
216
217 #[must_use]
224 pub fn with_deadline_secs(mut self, deadline_secs: u64) -> Self {
225 self.deadline_secs = deadline_secs.max(1);
226 self
227 }
228
229 #[must_use]
231 pub fn with_wait_timeout(mut self, wait_timeout: Duration) -> Self {
232 self.wait_timeout = wait_timeout;
233 self
234 }
235
236 #[must_use]
238 pub fn with_poll_interval(mut self, poll_interval: Duration) -> Self {
239 self.poll_interval = poll_interval;
240 self
241 }
242
243 pub async fn split_position(
250 &self,
251 condition_id: &str,
252 amount: Decimal,
253 ) -> Result<PolymarketPositionTransaction> {
254 let neg_risk = self.market_neg_risk(condition_id).await?;
255 let call = encode_split_position(condition_id, amount, neg_risk)?;
256 self.submit_call(call, "Split position").await
257 }
258
259 pub async fn merge_positions(
266 &self,
267 condition_id: &str,
268 amount: Decimal,
269 ) -> Result<PolymarketPositionTransaction> {
270 let neg_risk = self.market_neg_risk(condition_id).await?;
271 let call = encode_merge_positions(condition_id, amount, neg_risk)?;
272 self.submit_call(call, "Merge positions").await
273 }
274
275 pub async fn redeem_positions(
282 &self,
283 condition_id: &str,
284 ) -> Result<PolymarketPositionTransaction> {
285 let neg_risk = self.market_neg_risk(condition_id).await?;
286 let call = encode_redeem_positions(condition_id, neg_risk)?;
287 self.submit_call(call, "Redeem positions").await
288 }
289
290 async fn market_neg_risk(&self, condition_id: &str) -> Result<bool> {
291 let market = self.clob.get_market(condition_id).await?;
292 validate_market_version(market.version.as_deref())
293 .map_err(|e| Error::bad_request(e.to_string()))?;
294 market.neg_risk.ok_or_else(|| {
295 Error::bad_request(format!(
296 "market metadata for {condition_id} is missing neg_risk"
297 ))
298 })
299 }
300
301 async fn submit_call(
302 &self,
303 call: PositionCall,
304 metadata: &str,
305 ) -> Result<PolymarketPositionTransaction> {
306 let mut submission = self.submission.lock().await;
307 if let Some(previous) = submission.as_ref() {
308 let Some(transaction_id) = previous.transaction_id.as_deref() else {
309 return Err(Error::exchange(
310 "Previous Deposit Wallet submit outcome is unknown; reconcile it before further operations",
311 ));
312 };
313
314 let transaction = self.relayer.get_transaction(transaction_id).await?;
315 if !transaction.state.is_terminal() {
316 return Err(Error::exchange(format!(
317 "Deposit Wallet transaction {transaction_id} is still pending"
318 )));
319 }
320 }
321
322 let nonce = self
323 .wallet
324 .verify(self.signer.address(), self.deposit_wallet)
325 .await?;
326 let now = U256::from(self.clock.get_time_ns().as_u64() / 1_000_000_000);
327
328 if let Some(previous) = submission.as_ref()
329 && nonce <= previous.nonce
330 {
331 return Err(Error::exchange(
332 "Deposit Wallet nonce has not advanced; reconcile the previous submission before further operations",
333 ));
334 }
335
336 let deadline = now.saturating_add(U256::from(self.deadline_secs));
337
338 let wallet_call = DepositWalletCall {
339 target: call.target,
340 value: U256::ZERO,
341 data: call.data,
342 };
343
344 let signature = self.signer.sign_deposit_wallet_batch(
345 self.deposit_wallet,
346 nonce,
347 deadline,
348 std::slice::from_ref(&wallet_call),
349 )?;
350 *submission = Some(WalletSubmission {
351 nonce,
352 transaction_id: None,
353 });
354
355 log::info!(
356 "Deposit Wallet submission: wallet={:#x}, nonce={nonce}, deadline={deadline}, operation={metadata}, target={:#x}, value={}, data={}",
357 self.deposit_wallet,
358 wallet_call.target,
359 wallet_call.value,
360 wallet_call.data,
361 );
362
363 let submitted = self
364 .relayer
365 .submit_wallet_batch(RelayerWalletSubmit {
366 signer: self.signer.address(),
367 deposit_wallet: self.deposit_wallet,
368 nonce,
369 deadline,
370 signature: &signature,
371 metadata,
372 calls: std::slice::from_ref(&wallet_call),
373 })
374 .await?;
375
376 let Some(transaction_id) = submitted.transaction_id else {
377 return Err(Error::decode(
378 "Relayer submit response omitted transaction_id; transaction outcome is unknown",
379 ));
380 };
381
382 *submission = Some(WalletSubmission {
383 nonce,
384 transaction_id: Some(transaction_id.clone()),
385 });
386
387 Ok(PolymarketPositionTransaction {
388 relayer: self.relayer.clone(),
389 transaction_id,
390 wait_timeout: self.wait_timeout,
391 poll_interval: self.poll_interval,
392 })
393 }
394}
395
396#[derive(Debug)]
397struct WalletSubmission {
398 nonce: U256,
399 transaction_id: Option<String>,
400}
401
402type WalletSubmissionState = tokio::sync::Mutex<Option<WalletSubmission>>;
403
404fn wallet_submission(wallet: Address) -> Arc<WalletSubmissionState> {
405 let mut submissions = WALLET_SUBMISSIONS.lock();
406 submissions.retain(|_, state| {
407 Arc::strong_count(state) > 1 || state.try_lock().map_or(true, |state| state.is_some())
408 });
409
410 submissions.entry(wallet).or_default().clone()
411}
412
413fn outcome_from_transaction(tx: RelayerTransaction) -> Result<PolymarketPositionOutcome> {
414 let transaction_id = tx.transaction_id.clone().ok_or_else(|| {
415 Error::decode(
416 "Relayer terminal response omitted transaction_id; transaction outcome is unknown",
417 )
418 })?;
419
420 match tx.state {
421 RelayerTransactionState::Confirmed => Ok(PolymarketPositionOutcome::Confirmed {
422 transaction_id,
423 transaction_hash: tx.transaction_hash,
424 }),
425 RelayerTransactionState::Failed => Ok(PolymarketPositionOutcome::Failed {
426 transaction_id,
427 transaction_hash: tx.transaction_hash,
428 error_msg: tx.error_msg,
429 }),
430 RelayerTransactionState::Invalid => Ok(PolymarketPositionOutcome::Invalid {
431 transaction_id,
432 error_msg: tx.error_msg,
433 }),
434 RelayerTransactionState::New | RelayerTransactionState::Other(_) => {
435 Err(Error::decode(format!(
436 "Relayer transaction {transaction_id} is not terminal, state was {}",
437 tx.state.as_str()
438 )))
439 }
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use nautilus_network::retry::RetryConfig;
446 use rstest::rstest;
447
448 use super::*;
449
450 #[tokio::test]
451 #[ignore = "requires read-only access to live Polymarket Gamma and CLOB APIs"]
452 async fn test_live_market_protocol_refusal() {
453 let key = EvmPrivateKey::new(
454 "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
455 )
456 .unwrap();
457 let relayer_key = RelayerApiKey::new(
458 "unused".into(),
459 "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
460 )
461 .unwrap();
462 let client = PolymarketPositionClient::new(
463 &key,
464 "0x1111111111111111111111111111111111111111",
465 relayer_key,
466 Some("http://127.0.0.1:1".into()),
467 None,
468 Some(20),
469 None,
470 )
471 .unwrap();
472 let gamma =
473 crate::http::gamma::PolymarketGammaHttpClient::new(None, 20, RetryConfig::default())
474 .unwrap();
475
476 for (slug, supported) in [
477 ("xi-jinping-out-before-2027", true),
478 ("polyv2bkbsl-cay-bur-2026-10-23", false),
479 ] {
480 let markets = vec![gamma.inner().get_gamma_market_by_slug(slug).await.unwrap()];
481 let market = &markets[0];
482 assert_eq!(
483 market.version.as_deref(),
484 Some(if supported { "v1" } else { "v2" })
485 );
486 let (instruments, transient) =
487 crate::http::gamma::parse_markets_with_transient(&markets, 1.into());
488 assert_eq!(instruments.len(), if supported { 2 } else { 0 });
489 assert_eq!(transient, Vec::<String>::new());
490 let result = client.market_neg_risk(&market.condition_id).await;
491
492 if supported {
493 assert_eq!(result.unwrap(), market.neg_risk.unwrap());
494 } else {
495 assert!(
496 result
497 .unwrap_err()
498 .to_string()
499 .contains("Unsupported Polymarket market protocol v2")
500 );
501 }
502 }
503 }
504
505 #[rstest]
506 fn test_outcome_from_confirmed_transaction() {
507 let outcome = outcome_from_transaction(RelayerTransaction {
508 transaction_id: Some("tx-1".into()),
509 transaction_hash: Some("0xabc".into()),
510 state: RelayerTransactionState::Confirmed,
511 error_msg: None,
512 })
513 .unwrap();
514
515 assert_eq!(
516 outcome,
517 PolymarketPositionOutcome::Confirmed {
518 transaction_id: "tx-1".into(),
519 transaction_hash: Some("0xabc".into()),
520 }
521 );
522 }
523
524 #[rstest]
525 fn test_outcome_from_failed_and_invalid_transactions() {
526 let failed = outcome_from_transaction(RelayerTransaction {
527 transaction_id: Some("tx-2".into()),
528 transaction_hash: None,
529 state: RelayerTransactionState::Failed,
530 error_msg: Some("reverted".into()),
531 })
532 .unwrap();
533
534 assert_eq!(
535 failed,
536 PolymarketPositionOutcome::Failed {
537 transaction_id: "tx-2".into(),
538 transaction_hash: None,
539 error_msg: Some("reverted".into()),
540 }
541 );
542
543 let invalid = outcome_from_transaction(RelayerTransaction {
544 transaction_id: Some("tx-3".into()),
545 transaction_hash: None,
546 state: RelayerTransactionState::Invalid,
547 error_msg: Some("bad nonce".into()),
548 })
549 .unwrap();
550
551 assert_eq!(
552 invalid,
553 PolymarketPositionOutcome::Invalid {
554 transaction_id: "tx-3".into(),
555 error_msg: Some("bad nonce".into()),
556 }
557 );
558 }
559
560 #[rstest]
561 fn test_outcome_from_non_terminal_is_error() {
562 let err = outcome_from_transaction(RelayerTransaction {
563 transaction_id: Some("tx-4".into()),
564 transaction_hash: None,
565 state: RelayerTransactionState::New,
566 error_msg: None,
567 })
568 .unwrap_err();
569
570 assert!(err.to_string().contains("is not terminal"));
571 assert!(err.to_string().contains("state was STATE_NEW"));
572 }
573}