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::{
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/// Terminal result of a Polymarket position operation.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub enum PolymarketPositionOutcome {
65    /// Relayer reported `STATE_CONFIRMED`.
66    Confirmed {
67        /// Relayer transaction identifier.
68        transaction_id: String,
69        /// On-chain transaction hash when the Relayer supplied one.
70        transaction_hash: Option<String>,
71    },
72    /// Relayer reported `STATE_FAILED`.
73    Failed {
74        /// Relayer transaction identifier.
75        transaction_id: String,
76        /// On-chain transaction hash when the Relayer supplied one.
77        transaction_hash: Option<String>,
78        /// Relayer error detail when present.
79        error_msg: Option<String>,
80    },
81    /// Relayer reported `STATE_INVALID`.
82    Invalid {
83        /// Relayer transaction identifier.
84        transaction_id: String,
85        /// Relayer error detail when present.
86        error_msg: Option<String>,
87    },
88}
89
90/// Submitted position operation that can be polled to a terminal Relayer state.
91#[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    /// Relayer transaction identifier returned at submit time.
101    #[must_use]
102    pub fn transaction_id(&self) -> &str {
103        &self.transaction_id
104    }
105
106    /// Polls the Relayer until the transaction is confirmed, failed, or invalid.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error if polling fails with a non-retryable status or the wait
111    /// times out before a terminal state. A timeout leaves the on-chain outcome
112    /// unknown.
113    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/// Deposit Wallet client for split, merge, and redeem position operations.
145#[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    /// Creates a Deposit Wallet position client.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if credentials are invalid, the deposit wallet is not
165    /// distinct from the signer, or an HTTP client cannot be created.
166    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    /// Sets the Polygon RPC URL used to verify the Deposit Wallet before signing.
211    #[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    /// Sets the signed-batch deadline in seconds from now.
218    ///
219    /// Defaults to 1,800 seconds; values below one second are raised to one.
220    /// Longer deadlines extend the period in which a signed batch can execute and
221    /// delay expiry-based recovery of an unknown submission. The wait timeout does
222    /// not shorten this deadline or cancel the signed batch.
223    #[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    /// Sets how long [`PolymarketPositionTransaction::wait`] polls before timing out.
230    #[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    /// Sets the Relayer poll interval used by [`PolymarketPositionTransaction::wait`].
237    #[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    /// Splits `amount` pUSD into a complete set of outcome tokens.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if market metadata is invalid, encoding fails, or Relayer
248    /// submission is rejected or ambiguous.
249    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    /// Merges `amount` complete sets of outcome tokens back into pUSD.
260    ///
261    /// # Errors
262    ///
263    /// Returns an error if market metadata is invalid, encoding fails, or Relayer
264    /// submission is rejected or ambiguous.
265    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    /// Redeems both binary outcome balances for a resolved market.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error if market metadata is invalid, encoding fails, or Relayer
280    /// submission is rejected or ambiguous.
281    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}