Skip to main content

nautilus_polymarket/positions/
mod.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Deposit Wallet split, merge, and redeem operations for Polymarket positions.
17
18pub 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/// Terminal result of a Polymarket position operation.
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub enum PolymarketPositionOutcome {
62    /// Relayer reported `STATE_CONFIRMED`.
63    Confirmed {
64        /// Relayer transaction identifier.
65        transaction_id: String,
66        /// On-chain transaction hash when the Relayer supplied one.
67        transaction_hash: Option<String>,
68    },
69    /// Relayer reported `STATE_FAILED`.
70    Failed {
71        /// Relayer transaction identifier.
72        transaction_id: String,
73        /// On-chain transaction hash when the Relayer supplied one.
74        transaction_hash: Option<String>,
75        /// Relayer error detail when present.
76        error_msg: Option<String>,
77    },
78    /// Relayer reported `STATE_INVALID`.
79    Invalid {
80        /// Relayer transaction identifier.
81        transaction_id: String,
82        /// Relayer error detail when present.
83        error_msg: Option<String>,
84    },
85}
86
87/// Submitted position operation that can be polled to a terminal Relayer state.
88#[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    /// Relayer transaction identifier returned at submit time.
98    #[must_use]
99    pub fn transaction_id(&self) -> &str {
100        &self.transaction_id
101    }
102
103    /// Polls the Relayer until the transaction is confirmed, failed, or invalid.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if polling fails with a non-retryable status or the wait
108    /// times out before a terminal state. A timeout leaves the on-chain outcome
109    /// unknown.
110    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/// Deposit Wallet client for split, merge, and redeem position operations.
142#[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    /// Creates a Deposit Wallet position client.
158    ///
159    /// # Errors
160    ///
161    /// Returns an error if credentials are invalid, the deposit wallet is not
162    /// distinct from the signer, or an HTTP client cannot be created.
163    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    /// Sets the Polygon RPC URL used to verify the Deposit Wallet before signing.
208    #[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    /// Sets the signed-batch deadline in seconds from now.
215    ///
216    /// Defaults to 1,800 seconds; values below one second are raised to one.
217    /// Longer deadlines extend the period in which a signed batch can execute and
218    /// delay expiry-based recovery of an unknown submission. The wait timeout does
219    /// not shorten this deadline or cancel the signed batch.
220    #[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    /// Sets how long [`PolymarketPositionTransaction::wait`] polls before timing out.
227    #[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    /// Sets the Relayer poll interval used by [`PolymarketPositionTransaction::wait`].
234    #[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    /// Splits `amount` pUSD into a complete set of outcome tokens.
241    ///
242    /// # Errors
243    ///
244    /// Returns an error if market metadata is invalid, encoding fails, or Relayer
245    /// submission is rejected or ambiguous.
246    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    /// Merges `amount` complete sets of outcome tokens back into pUSD.
257    ///
258    /// # Errors
259    ///
260    /// Returns an error if market metadata is invalid, encoding fails, or Relayer
261    /// submission is rejected or ambiguous.
262    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    /// Redeems both binary outcome balances for a resolved market.
273    ///
274    /// # Errors
275    ///
276    /// Returns an error if market metadata is invalid, encoding fails, or Relayer
277    /// submission is rejected or ambiguous.
278    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}