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::credential::{EvmPrivateKey, RelayerApiKey},
40 http::{
41 clob::PolymarketClobPublicClient,
42 error::{Error, Result},
43 relayer::{
44 PolymarketRelayerHttpClient, RelayerTransaction, RelayerTransactionState,
45 RelayerWalletSubmit,
46 },
47 },
48 signing::eip712::{DepositWalletCall, OrderSigner, parse_address},
49};
50
51const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 60;
52const DEFAULT_DEADLINE_SECS: u64 = 1_800;
53const DEFAULT_WAIT_TIMEOUT_SECS: u64 = 120;
54const DEFAULT_POLL_INTERVAL_MS: u64 = 1_000;
55
56static WALLET_SUBMISSIONS: LazyLock<Mutex<HashMap<Address, Arc<WalletSubmissionState>>>> =
57 LazyLock::new(|| Mutex::new(HashMap::new()));
58
59#[derive(Clone, Debug, PartialEq, Eq)]
61pub enum PolymarketPositionOutcome {
62 Confirmed {
64 transaction_id: String,
66 transaction_hash: Option<String>,
68 },
69 Failed {
71 transaction_id: String,
73 transaction_hash: Option<String>,
75 error_msg: Option<String>,
77 },
78 Invalid {
80 transaction_id: String,
82 error_msg: Option<String>,
84 },
85}
86
87#[derive(Debug)]
89pub struct PolymarketPositionTransaction {
90 relayer: PolymarketRelayerHttpClient,
91 transaction_id: String,
92 wait_timeout: Duration,
93 poll_interval: Duration,
94}
95
96impl PolymarketPositionTransaction {
97 #[must_use]
99 pub fn transaction_id(&self) -> &str {
100 &self.transaction_id
101 }
102
103 pub async fn wait(self) -> Result<PolymarketPositionOutcome> {
111 let deadline = Instant::now() + self.wait_timeout;
112
113 loop {
114 match self.relayer.get_transaction(&self.transaction_id).await {
115 Ok(tx) => {
116 if tx.state.is_terminal() {
117 return outcome_from_transaction(tx);
118 }
119 }
120 Err(e) if e.is_retryable() => {
121 log::warn!(
122 "Relayer transaction {} poll failed: {e}; retrying",
123 self.transaction_id
124 );
125 }
126 Err(e) => return Err(e),
127 }
128
129 if Instant::now() >= deadline {
130 return Err(Error::exchange(format!(
131 "Relayer transaction {} wait timed out; terminal state is unknown",
132 self.transaction_id
133 )));
134 }
135
136 tokio::time::sleep(self.poll_interval).await;
137 }
138 }
139}
140
141#[derive(Debug)]
143pub struct PolymarketPositionClient {
144 signer: OrderSigner,
145 deposit_wallet: Address,
146 relayer: PolymarketRelayerHttpClient,
147 clob: PolymarketClobPublicClient,
148 clock: &'static AtomicTime,
149 deadline_secs: u64,
150 wait_timeout: Duration,
151 poll_interval: Duration,
152 wallet: wallet::WalletVerifier,
153 submission: Arc<WalletSubmissionState>,
154}
155
156impl PolymarketPositionClient {
157 pub fn new(
164 private_key: &EvmPrivateKey,
165 deposit_wallet: &str,
166 relayer_api_key: RelayerApiKey,
167 base_url_relayer: Option<String>,
168 base_url_clob: Option<String>,
169 timeout_secs: Option<u64>,
170 proxy_url: Option<ProxyUrl>,
171 ) -> Result<Self> {
172 let timeout_secs = timeout_secs.unwrap_or(DEFAULT_HTTP_TIMEOUT_SECS);
173 let signer = OrderSigner::new(private_key)?;
174 let deposit_wallet = parse_address(deposit_wallet, "deposit_wallet")?;
175 if deposit_wallet == signer.address() {
176 return Err(Error::bad_request(
177 "Deposit Wallet operations require a funder distinct from the signing address",
178 ));
179 }
180
181 let relayer = PolymarketRelayerHttpClient::new_with_proxy(
182 relayer_api_key,
183 base_url_relayer,
184 timeout_secs,
185 proxy_url.clone(),
186 )
187 .map_err(Error::from_http_client)?;
188 let wallet = wallet::WalletVerifier::new(timeout_secs, proxy_url.clone())?;
189 let clob =
190 PolymarketClobPublicClient::new_with_proxy(base_url_clob, timeout_secs, proxy_url)
191 .map_err(Error::from_http_client)?;
192
193 Ok(Self {
194 signer,
195 deposit_wallet,
196 relayer,
197 clob,
198 clock: get_atomic_clock_realtime(),
199 deadline_secs: DEFAULT_DEADLINE_SECS,
200 wait_timeout: Duration::from_secs(DEFAULT_WAIT_TIMEOUT_SECS),
201 poll_interval: Duration::from_millis(DEFAULT_POLL_INTERVAL_MS),
202 wallet,
203 submission: wallet_submission(deposit_wallet),
204 })
205 }
206
207 #[must_use]
209 pub fn with_rpc_url(mut self, rpc_url: String) -> Self {
210 self.wallet.set_rpc_url(rpc_url);
211 self
212 }
213
214 #[must_use]
221 pub fn with_deadline_secs(mut self, deadline_secs: u64) -> Self {
222 self.deadline_secs = deadline_secs.max(1);
223 self
224 }
225
226 #[must_use]
228 pub fn with_wait_timeout(mut self, wait_timeout: Duration) -> Self {
229 self.wait_timeout = wait_timeout;
230 self
231 }
232
233 #[must_use]
235 pub fn with_poll_interval(mut self, poll_interval: Duration) -> Self {
236 self.poll_interval = poll_interval;
237 self
238 }
239
240 pub async fn split_position(
247 &self,
248 condition_id: &str,
249 amount: Decimal,
250 ) -> Result<PolymarketPositionTransaction> {
251 let neg_risk = self.market_neg_risk(condition_id).await?;
252 let call = encode_split_position(condition_id, amount, neg_risk)?;
253 self.submit_call(call, "Split position").await
254 }
255
256 pub async fn merge_positions(
263 &self,
264 condition_id: &str,
265 amount: Decimal,
266 ) -> Result<PolymarketPositionTransaction> {
267 let neg_risk = self.market_neg_risk(condition_id).await?;
268 let call = encode_merge_positions(condition_id, amount, neg_risk)?;
269 self.submit_call(call, "Merge positions").await
270 }
271
272 pub async fn redeem_positions(
279 &self,
280 condition_id: &str,
281 ) -> Result<PolymarketPositionTransaction> {
282 let neg_risk = self.market_neg_risk(condition_id).await?;
283 let call = encode_redeem_positions(condition_id, neg_risk)?;
284 self.submit_call(call, "Redeem positions").await
285 }
286
287 async fn market_neg_risk(&self, condition_id: &str) -> Result<bool> {
288 let market = self.clob.get_market(condition_id).await?;
289 market.neg_risk.ok_or_else(|| {
290 Error::bad_request(format!(
291 "market metadata for {condition_id} is missing neg_risk"
292 ))
293 })
294 }
295
296 async fn submit_call(
297 &self,
298 call: PositionCall,
299 metadata: &str,
300 ) -> Result<PolymarketPositionTransaction> {
301 let mut submission = self.submission.lock().await;
302 if let Some(previous) = submission.as_ref() {
303 let Some(transaction_id) = previous.transaction_id.as_deref() else {
304 return Err(Error::exchange(
305 "Previous Deposit Wallet submit outcome is unknown; reconcile it before further operations",
306 ));
307 };
308
309 let transaction = self.relayer.get_transaction(transaction_id).await?;
310 if !transaction.state.is_terminal() {
311 return Err(Error::exchange(format!(
312 "Deposit Wallet transaction {transaction_id} is still pending"
313 )));
314 }
315 }
316
317 let nonce = self
318 .wallet
319 .verify(self.signer.address(), self.deposit_wallet)
320 .await?;
321 let now = U256::from(self.clock.get_time_ns().as_u64() / 1_000_000_000);
322
323 if let Some(previous) = submission.as_ref()
324 && nonce <= previous.nonce
325 {
326 return Err(Error::exchange(
327 "Deposit Wallet nonce has not advanced; reconcile the previous submission before further operations",
328 ));
329 }
330
331 let deadline = now.saturating_add(U256::from(self.deadline_secs));
332
333 let wallet_call = DepositWalletCall {
334 target: call.target,
335 value: U256::ZERO,
336 data: call.data,
337 };
338
339 let signature = self.signer.sign_deposit_wallet_batch(
340 self.deposit_wallet,
341 nonce,
342 deadline,
343 std::slice::from_ref(&wallet_call),
344 )?;
345 *submission = Some(WalletSubmission {
346 nonce,
347 transaction_id: None,
348 });
349
350 log::info!(
351 "Deposit Wallet submission: wallet={:#x}, nonce={nonce}, deadline={deadline}, operation={metadata}, target={:#x}, value={}, data={}",
352 self.deposit_wallet,
353 wallet_call.target,
354 wallet_call.value,
355 wallet_call.data,
356 );
357
358 let submitted = self
359 .relayer
360 .submit_wallet_batch(RelayerWalletSubmit {
361 signer: self.signer.address(),
362 deposit_wallet: self.deposit_wallet,
363 nonce,
364 deadline,
365 signature: &signature,
366 metadata,
367 calls: std::slice::from_ref(&wallet_call),
368 })
369 .await?;
370
371 let Some(transaction_id) = submitted.transaction_id else {
372 return Err(Error::decode(
373 "Relayer submit response omitted transaction_id; transaction outcome is unknown",
374 ));
375 };
376
377 *submission = Some(WalletSubmission {
378 nonce,
379 transaction_id: Some(transaction_id.clone()),
380 });
381
382 Ok(PolymarketPositionTransaction {
383 relayer: self.relayer.clone(),
384 transaction_id,
385 wait_timeout: self.wait_timeout,
386 poll_interval: self.poll_interval,
387 })
388 }
389}
390
391#[derive(Debug)]
392struct WalletSubmission {
393 nonce: U256,
394 transaction_id: Option<String>,
395}
396
397type WalletSubmissionState = tokio::sync::Mutex<Option<WalletSubmission>>;
398
399fn wallet_submission(wallet: Address) -> Arc<WalletSubmissionState> {
400 let mut submissions = WALLET_SUBMISSIONS.lock();
401 submissions.retain(|_, state| {
402 Arc::strong_count(state) > 1 || state.try_lock().map_or(true, |state| state.is_some())
403 });
404
405 submissions.entry(wallet).or_default().clone()
406}
407
408fn outcome_from_transaction(tx: RelayerTransaction) -> Result<PolymarketPositionOutcome> {
409 let transaction_id = tx.transaction_id.clone().ok_or_else(|| {
410 Error::decode(
411 "Relayer terminal response omitted transaction_id; transaction outcome is unknown",
412 )
413 })?;
414
415 match tx.state {
416 RelayerTransactionState::Confirmed => Ok(PolymarketPositionOutcome::Confirmed {
417 transaction_id,
418 transaction_hash: tx.transaction_hash,
419 }),
420 RelayerTransactionState::Failed => Ok(PolymarketPositionOutcome::Failed {
421 transaction_id,
422 transaction_hash: tx.transaction_hash,
423 error_msg: tx.error_msg,
424 }),
425 RelayerTransactionState::Invalid => Ok(PolymarketPositionOutcome::Invalid {
426 transaction_id,
427 error_msg: tx.error_msg,
428 }),
429 RelayerTransactionState::New | RelayerTransactionState::Other(_) => {
430 Err(Error::decode(format!(
431 "Relayer transaction {transaction_id} is not terminal, state was {}",
432 tx.state.as_str()
433 )))
434 }
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use rstest::rstest;
441
442 use super::*;
443
444 #[rstest]
445 fn test_outcome_from_confirmed_transaction() {
446 let outcome = outcome_from_transaction(RelayerTransaction {
447 transaction_id: Some("tx-1".into()),
448 transaction_hash: Some("0xabc".into()),
449 state: RelayerTransactionState::Confirmed,
450 error_msg: None,
451 })
452 .unwrap();
453
454 assert_eq!(
455 outcome,
456 PolymarketPositionOutcome::Confirmed {
457 transaction_id: "tx-1".into(),
458 transaction_hash: Some("0xabc".into()),
459 }
460 );
461 }
462
463 #[rstest]
464 fn test_outcome_from_failed_and_invalid_transactions() {
465 let failed = outcome_from_transaction(RelayerTransaction {
466 transaction_id: Some("tx-2".into()),
467 transaction_hash: None,
468 state: RelayerTransactionState::Failed,
469 error_msg: Some("reverted".into()),
470 })
471 .unwrap();
472
473 assert_eq!(
474 failed,
475 PolymarketPositionOutcome::Failed {
476 transaction_id: "tx-2".into(),
477 transaction_hash: None,
478 error_msg: Some("reverted".into()),
479 }
480 );
481
482 let invalid = outcome_from_transaction(RelayerTransaction {
483 transaction_id: Some("tx-3".into()),
484 transaction_hash: None,
485 state: RelayerTransactionState::Invalid,
486 error_msg: Some("bad nonce".into()),
487 })
488 .unwrap();
489
490 assert_eq!(
491 invalid,
492 PolymarketPositionOutcome::Invalid {
493 transaction_id: "tx-3".into(),
494 error_msg: Some("bad nonce".into()),
495 }
496 );
497 }
498
499 #[rstest]
500 fn test_outcome_from_non_terminal_is_error() {
501 let err = outcome_from_transaction(RelayerTransaction {
502 transaction_id: Some("tx-4".into()),
503 transaction_hash: None,
504 state: RelayerTransactionState::New,
505 error_msg: None,
506 })
507 .unwrap_err();
508
509 assert!(err.to_string().contains("is not terminal"));
510 assert!(err.to_string().contains("state was STATE_NEW"));
511 }
512}