Skip to main content

nautilus_polymarket/
session.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//! Owner-operated administration of Deposit Wallet session keys.
17
18use std::time::Duration;
19
20use alloy::sol_types::SolCall;
21use alloy_primitives::{Address, U256};
22use nautilus_core::{string::secret::SecretString, time::get_atomic_clock_realtime};
23use nautilus_network::websocket::proxy::ProxyUrl;
24use serde::{Deserialize, Serialize};
25use uuid::Uuid;
26
27use crate::{
28    common::credential::{Credential, EvmPrivateKey},
29    http::{
30        clob::PolymarketClobHttpClient,
31        error::{Error, Result},
32        relayer::{PolymarketRelayerHttpClient, RelayerTransactionState},
33    },
34    signing::eip712::{DepositWalletCall, OrderSigner},
35};
36
37// The official SDK uses 4,315 hours, despite the documentation's rounded 180 days.
38const SESSION_LIFETIME_SECS: u64 = 4_315 * 60 * 60;
39const BATCH_DEADLINE_SECS: u64 = 600;
40const POLL_INTERVAL: Duration = Duration::from_secs(2);
41const CONFIRMATION_TIMEOUT: Duration = Duration::from_secs(200);
42
43/// Explicit owner and Builder credentials for session administration.
44///
45/// Keep this configuration outside the trading runtime. Credentials do not fall back to the environment.
46#[derive(Debug, Clone)]
47pub struct PolymarketSessionKeyClientConfig {
48    pub private_key: SecretString,
49    pub api_key: SecretString,
50    pub api_secret: SecretString,
51    pub passphrase: SecretString,
52    pub builder_api_key: SecretString,
53    pub builder_api_secret: SecretString,
54    pub builder_passphrase: SecretString,
55    pub funder: String,
56    pub base_url_http: Option<String>,
57    pub base_url_relayer: Option<String>,
58    pub proxy_url: Option<SecretString>,
59}
60
61/// Active session authorization. Contains no private key or API credentials.
62#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
63pub struct PolymarketSessionKey {
64    pub address: String,
65    pub scopes: Vec<String>,
66    /// Expiration as whole Unix seconds.
67    pub valid_until: u64,
68}
69
70/// Owner-operated authorization, inspection, and revocation for a Deposit Wallet.
71#[derive(Debug)]
72pub struct PolymarketSessionKeyClient {
73    signer: OrderSigner,
74    wallet: Address,
75    clob: PolymarketClobHttpClient,
76    relayer: PolymarketRelayerHttpClient,
77    mutation: tokio::sync::Mutex<Option<SessionMutation>>,
78}
79
80impl PolymarketSessionKeyClient {
81    /// Creates an administration client with explicit credentials.
82    ///
83    /// # Errors
84    ///
85    /// Returns an error for invalid keys, wallet address, proxy, or HTTP configuration.
86    pub fn new(config: PolymarketSessionKeyClientConfig) -> Result<Self> {
87        let wallet = parse_address(&config.funder)?;
88        let signer = OrderSigner::new(&EvmPrivateKey::new(config.private_key.expose_secret())?)?;
89        let proxy = config
90            .proxy_url
91            .as_ref()
92            .map(|url| ProxyUrl::parse(url.expose_secret().to_owned()))
93            .transpose()
94            .map_err(|_| Error::bad_request("Invalid session administration proxy URL"))?;
95        let credential = Credential::new(config.api_key, config.api_secret, config.passphrase)?;
96
97        let builder = Credential::new(
98            config.builder_api_key,
99            config.builder_api_secret,
100            config.builder_passphrase,
101        )?;
102        let clob = PolymarketClobHttpClient::new_with_proxy(
103            credential,
104            format!("{:#x}", signer.address()),
105            config.base_url_http,
106            30,
107            proxy.clone(),
108        )
109        .map_err(Error::from_http_client)?;
110        let relayer = PolymarketRelayerHttpClient::new_with_builder(
111            builder,
112            config.base_url_relayer,
113            300,
114            proxy,
115        )
116        .map_err(Error::from_http_client)?;
117        Ok(Self {
118            signer,
119            wallet,
120            clob,
121            relayer,
122            mutation: tokio::sync::Mutex::new(None),
123        })
124    }
125
126    /// Lists the wallet's active, unexpired session authorizations using owner CLOB credentials.
127    ///
128    /// # Errors
129    ///
130    /// Returns an error on authentication failure, malformed data, or a different response wallet.
131    pub async fn list_session_keys(&self) -> Result<Vec<PolymarketSessionKey>> {
132        let mut response = self.clob.list_session_keys().await?;
133        if parse_address(&response.wallet)? != self.wallet {
134            return Err(Error::decode(
135                "Session registry returned a different Deposit Wallet",
136            ));
137        }
138
139        for key in &response.signers {
140            parse_address(&key.address)?;
141            if key.scopes.is_empty() || key.scopes.iter().any(|scope| scope.trim().is_empty()) {
142                return Err(Error::decode("Session registry returned empty scopes"));
143            }
144        }
145
146        let now = unix_seconds();
147        response.signers.retain(|key| key.valid_until > now);
148        Ok(response.signers)
149    }
150
151    /// Authorizes a public session address for CLOB trading.
152    ///
153    /// Returns only after on-chain confirmation and matching registry visibility.
154    /// The caller generates and stores the session private key separately.
155    ///
156    /// # Errors
157    ///
158    /// Returns an error on rejection, failed confirmation, timeout, or unknown submission outcome.
159    pub async fn authorize_session_key(&self, address: &str) -> Result<PolymarketSessionKey> {
160        self.mutate(address, true).await?.ok_or_else(|| {
161            Error::decode("Session authorization completed without registry metadata")
162        })
163    }
164
165    /// Revokes a session and waits for on-chain confirmation and removal from the active registry.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error on rejection, timeout, or unknown submission outcome.
170    /// Repeat the same operation on this client to resume an unresolved request.
171    pub async fn revoke_session_key(&self, address: &str) -> Result<()> {
172        self.mutate(address, false).await.map(|_| ())
173    }
174
175    async fn mutate(&self, address: &str, authorize: bool) -> Result<Option<PolymarketSessionKey>> {
176        let address = self.session_address(address)?;
177        let mut mutation = self.mutation.lock().await;
178        if let Some(pending) = mutation.as_ref() {
179            if pending.address != address || pending.valid_until.is_some() != authorize {
180                return Err(pending.unresolved("Resume the previous session operation first"));
181            }
182        } else {
183            let valid_until = authorize.then(|| unix_seconds() + SESSION_LIFETIME_SECS);
184
185            let data = match valid_until {
186                Some(valid_until) => authorizeSessionSignerCall {
187                    sessionSigner: address,
188                    validUntil: U256::from(valid_until),
189                }
190                .abi_encode(),
191                None => revokeSessionSignerCall {
192                    sessionSigner: address,
193                }
194                .abi_encode(),
195            };
196
197            let request = self.signed_request(address, data, valid_until).await?;
198            *mutation = Some(SessionMutation {
199                address,
200                valid_until,
201                body: serde_json::to_string(&request)?.into(),
202                idempotency_key: Uuid::new_v4().to_string(),
203                transaction_id: None,
204                attempted: false,
205                rejected: false,
206            });
207        }
208
209        // Persist before the first POST await so cancellation cannot discard the signed request.
210        let pending = mutation
211            .as_mut()
212            .ok_or_else(|| Error::exchange("Missing session operation"))?;
213        let result = self.submit(pending).await;
214        if let Err(e) = result {
215            if pending.rejected {
216                *mutation = None;
217                return Err(e);
218            }
219
220            return Err(pending.unresolved(&e.to_string()));
221        }
222
223        let result = tokio::time::timeout(CONFIRMATION_TIMEOUT, self.confirm(pending)).await;
224        match result {
225            Ok(Ok(key)) => {
226                *mutation = None;
227                Ok(key)
228            }
229            Ok(Err(e)) if pending.rejected => {
230                *mutation = None;
231                Err(e)
232            }
233            Ok(Err(e)) => Err(pending.unresolved(&e.to_string())),
234            Err(_) => Err(pending.unresolved("Session confirmation timed out")),
235        }
236    }
237
238    async fn confirm(&self, pending: &mut SessionMutation) -> Result<Option<PolymarketSessionKey>> {
239        let transaction_id = pending
240            .transaction_id
241            .as_deref()
242            .ok_or_else(|| Error::decode("Session response omitted its transaction ID"))?;
243
244        loop {
245            match self.relayer.get_transaction(transaction_id).await {
246                Ok(transaction) => match transaction.state {
247                    RelayerTransactionState::Confirmed => break,
248                    RelayerTransactionState::Failed | RelayerTransactionState::Invalid => {
249                        pending.rejected = true;
250                        return Err(Error::exchange(format!(
251                            "Session transaction failed: {transaction_id}"
252                        )));
253                    }
254                    _ => {}
255                },
256                Err(e) if retryable_read(&e) => {}
257                Err(e) => return Err(e),
258            }
259
260            tokio::time::sleep(POLL_INTERVAL).await;
261        }
262
263        loop {
264            match self.list_session_keys().await {
265                Ok(keys) => {
266                    let key = keys.into_iter().find(|key| {
267                        key.address
268                            .eq_ignore_ascii_case(&format!("{:#x}", pending.address))
269                    });
270
271                    match (pending.valid_until, key) {
272                        (Some(valid_until), Some(key))
273                            if key.valid_until == valid_until
274                                && key.valid_until > unix_seconds()
275                                && key.scopes == ["CLOB"] =>
276                        {
277                            return Ok(Some(key));
278                        }
279                        (None, None) => return Ok(None),
280                        _ => {}
281                    }
282                }
283                Err(e) if retryable_read(&e) => {}
284                Err(e) => return Err(e),
285            }
286
287            tokio::time::sleep(POLL_INTERVAL).await;
288        }
289    }
290
291    fn session_address(&self, address: &str) -> Result<Address> {
292        let address = parse_address(address)?;
293        if address == self.signer.address() || address == self.wallet {
294            return Err(Error::bad_request(
295                "Session signer must differ from the owner and Deposit Wallet",
296            ));
297        }
298
299        Ok(address)
300    }
301
302    async fn signed_request(
303        &self,
304        address: Address,
305        data: Vec<u8>,
306        valid_until: Option<u64>,
307    ) -> Result<SessionRequest> {
308        let nonce = self.relayer.get_wallet_nonce(self.signer.address()).await?;
309        let deadline = U256::from(unix_seconds() + BATCH_DEADLINE_SECS);
310
311        let calls = [DepositWalletCall {
312            target: self.wallet,
313            value: U256::ZERO,
314            data: data.into(),
315        }];
316
317        let signature =
318            self.signer
319                .sign_deposit_wallet_batch(self.wallet, nonce, deadline, &calls)?;
320        Ok(SessionRequest {
321            wallet_address: format!("{:#x}", self.wallet),
322            session_signer_address: format!("{address:#x}"),
323            nonce: nonce.to_string(),
324            deadline: deadline.to_string(),
325            signature,
326            valid_until: valid_until.map(|value| value.to_string()),
327            scopes: valid_until.map(|_| vec!["CLOB"]),
328        })
329    }
330
331    async fn submit(&self, pending: &mut SessionMutation) -> Result<()> {
332        if pending.transaction_id.is_some() {
333            return Ok(());
334        }
335
336        let authorize = pending.valid_until.is_some();
337
338        let path = if authorize {
339            "/v1/session-signers/authorizations"
340        } else {
341            "/v1/session-signers/revocations"
342        };
343
344        for attempt in 0..3 {
345            let previously_attempted = pending.attempted;
346            pending.attempted = true;
347            let result = self
348                .relayer
349                .post_session::<serde_json::Value>(
350                    path,
351                    pending.body.expose_secret(),
352                    &pending.idempotency_key,
353                )
354                .await;
355
356            match result {
357                Ok(response) => {
358                    let (status, transaction_id) = if authorize {
359                        let response: AuthorizationResponse = serde_json::from_value(response)?;
360
361                        if !matches!(
362                            response.status.as_str(),
363                            "SUBMITTED" | "REGISTRY_PENDING" | "REGISTERED"
364                        ) {
365                            pending.rejected = matches!(
366                                response.status.as_str(),
367                                "FAILED" | "SUPERSEDED" | "REPAIR_REQUIRED"
368                            );
369                            return Err(Error::exchange(format!(
370                                "Session authorization status {}",
371                                response.status
372                            )));
373                        }
374
375                        (response.status, response.transaction_id)
376                    } else {
377                        let response: RevocationResponse = serde_json::from_value(response)?;
378                        if !matches!(
379                            response.status.as_str(),
380                            "PENDING" | "FENCED" | "SWEPT" | "CHAIN_SUBMITTED" | "CONFIRMED"
381                        ) {
382                            pending.rejected = response.status == "FAILED";
383                            return Err(Error::exchange(format!(
384                                "Session revocation status {}",
385                                response.status
386                            )));
387                        }
388
389                        (response.status, response.transaction_id)
390                    };
391
392                    if transaction_id.trim().is_empty() {
393                        return Err(Error::decode(format!(
394                            "Session {status} response omitted its transaction ID"
395                        )));
396                    }
397
398                    pending.transaction_id = Some(transaction_id);
399                    return Ok(());
400                }
401                Err(e) if e.is_retryable() && attempt < 2 => {
402                    tokio::time::sleep(POLL_INTERVAL).await;
403                }
404                Err(e) => {
405                    pending.rejected = !previously_attempted && !e.is_submit_outcome_unknown();
406                    return Err(e);
407                }
408            }
409        }
410
411        Err(Error::exchange("Session submission retry budget exhausted"))
412    }
413}
414
415#[derive(Debug, Deserialize)]
416pub(crate) struct SessionKeysResponse {
417    pub wallet: String,
418    pub signers: Vec<PolymarketSessionKey>,
419}
420
421#[derive(Serialize)]
422#[serde(rename_all = "camelCase")]
423struct SessionRequest {
424    wallet_address: String,
425    session_signer_address: String,
426    nonce: String,
427    deadline: String,
428    signature: String,
429    #[serde(skip_serializing_if = "Option::is_none")]
430    valid_until: Option<String>,
431    #[serde(skip_serializing_if = "Option::is_none")]
432    scopes: Option<Vec<&'static str>>,
433}
434
435#[derive(Deserialize)]
436#[serde(rename_all = "camelCase")]
437struct AuthorizationResponse {
438    status: String,
439    transaction_id: String,
440}
441
442#[derive(Deserialize)]
443#[serde(rename_all = "camelCase")]
444struct RevocationResponse {
445    status: String,
446    transaction_id: String,
447    #[serde(rename = "fenced")]
448    _fenced: bool,
449}
450
451#[derive(Debug)]
452struct SessionMutation {
453    address: Address,
454    valid_until: Option<u64>,
455    body: SecretString,
456    idempotency_key: String,
457    transaction_id: Option<String>,
458    attempted: bool,
459    rejected: bool,
460}
461
462impl SessionMutation {
463    fn unresolved(&self, reason: &str) -> Error {
464        Error::exchange(format!(
465            "{reason}; session operation unresolved (idempotency_key={}, transaction_id={}); repeat the same operation on this client",
466            self.idempotency_key,
467            self.transaction_id.as_deref().unwrap_or("unknown"),
468        ))
469    }
470}
471
472fn retryable_read(e: &Error) -> bool {
473    e.is_retryable()
474        || matches!(
475            e,
476            Error::Http {
477                status: 404 | 409,
478                ..
479            }
480        )
481}
482
483alloy::sol! {
484    function authorizeSessionSigner(address sessionSigner, uint256 validUntil);
485    function revokeSessionSigner(address sessionSigner);
486}
487
488fn parse_address(value: &str) -> Result<Address> {
489    if !value.starts_with("0x") || value.len() != 42 {
490        return Err(Error::bad_request("Expected a 0x-prefixed EVM address"));
491    }
492
493    let address = value
494        .parse::<Address>()
495        .map_err(|_| Error::bad_request("Invalid EVM address"))?;
496    if address.is_zero() {
497        return Err(Error::bad_request("EVM address must not be zero"));
498    }
499
500    Ok(address)
501}
502
503fn unix_seconds() -> u64 {
504    get_atomic_clock_realtime().get_time_ns().as_u64() / 1_000_000_000
505}