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 if keys, wallet address, proxy, or HTTP configuration are invalid.
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 if authentication fails, data is malformed, or the response wallet differs.
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 if the request is rejected, confirmation fails, a timeout occurs, or the
159    /// submission outcome is unknown.
160    pub async fn authorize_session_key(&self, address: &str) -> Result<PolymarketSessionKey> {
161        self.mutate(address, true).await?.ok_or_else(|| {
162            Error::decode("Session authorization completed without registry metadata")
163        })
164    }
165
166    /// Revokes a session and waits for on-chain confirmation and removal from the active registry.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if the request is rejected, a timeout occurs, or the submission outcome is
171    /// unknown. Repeat the same operation on this client to resume an unresolved request.
172    pub async fn revoke_session_key(&self, address: &str) -> Result<()> {
173        self.mutate(address, false).await.map(|_| ())
174    }
175
176    async fn mutate(&self, address: &str, authorize: bool) -> Result<Option<PolymarketSessionKey>> {
177        let address = self.session_address(address)?;
178        let mut mutation = self.mutation.lock().await;
179        if let Some(pending) = mutation.as_ref() {
180            if pending.address != address || pending.valid_until.is_some() != authorize {
181                return Err(pending.unresolved("Resume the previous session operation first"));
182            }
183        } else {
184            let valid_until = authorize.then(|| unix_seconds() + SESSION_LIFETIME_SECS);
185
186            let data = match valid_until {
187                Some(valid_until) => authorizeSessionSignerCall {
188                    sessionSigner: address,
189                    validUntil: U256::from(valid_until),
190                }
191                .abi_encode(),
192                None => revokeSessionSignerCall {
193                    sessionSigner: address,
194                }
195                .abi_encode(),
196            };
197
198            let request = self.signed_request(address, data, valid_until).await?;
199            *mutation = Some(SessionMutation {
200                address,
201                valid_until,
202                body: serde_json::to_string(&request)?.into(),
203                idempotency_key: Uuid::new_v4().to_string(),
204                transaction_id: None,
205                attempted: false,
206                rejected: false,
207            });
208        }
209
210        // Persist before the first POST await so cancellation cannot discard the signed request.
211        let pending = mutation
212            .as_mut()
213            .ok_or_else(|| Error::exchange("Missing session operation"))?;
214        let result = self.submit(pending).await;
215        if let Err(e) = result {
216            if pending.rejected {
217                *mutation = None;
218                return Err(e);
219            }
220
221            return Err(pending.unresolved(&e.to_string()));
222        }
223
224        let result = tokio::time::timeout(CONFIRMATION_TIMEOUT, self.confirm(pending)).await;
225        match result {
226            Ok(Ok(key)) => {
227                *mutation = None;
228                Ok(key)
229            }
230            Ok(Err(e)) if pending.rejected => {
231                *mutation = None;
232                Err(e)
233            }
234            Ok(Err(e)) => Err(pending.unresolved(&e.to_string())),
235            Err(_) => Err(pending.unresolved("Session confirmation timed out")),
236        }
237    }
238
239    async fn confirm(&self, pending: &mut SessionMutation) -> Result<Option<PolymarketSessionKey>> {
240        let transaction_id = pending
241            .transaction_id
242            .as_deref()
243            .ok_or_else(|| Error::decode("Session response omitted its transaction ID"))?;
244
245        loop {
246            match self.relayer.get_transaction(transaction_id).await {
247                Ok(transaction) => match transaction.state {
248                    RelayerTransactionState::Confirmed => break,
249                    RelayerTransactionState::Failed | RelayerTransactionState::Invalid => {
250                        pending.rejected = true;
251                        return Err(Error::exchange(format!(
252                            "Session transaction failed: {transaction_id}"
253                        )));
254                    }
255                    _ => {}
256                },
257                Err(e) if retryable_read(&e) => {}
258                Err(e) => return Err(e),
259            }
260
261            tokio::time::sleep(POLL_INTERVAL).await;
262        }
263
264        loop {
265            match self.list_session_keys().await {
266                Ok(keys) => {
267                    let key = keys.into_iter().find(|key| {
268                        key.address
269                            .eq_ignore_ascii_case(&format!("{:#x}", pending.address))
270                    });
271
272                    match (pending.valid_until, key) {
273                        (Some(valid_until), Some(key))
274                            if key.valid_until == valid_until
275                                && key.valid_until > unix_seconds()
276                                && key.scopes == ["CLOB"] =>
277                        {
278                            return Ok(Some(key));
279                        }
280                        (None, None) => return Ok(None),
281                        _ => {}
282                    }
283                }
284                Err(e) if retryable_read(&e) => {}
285                Err(e) => return Err(e),
286            }
287
288            tokio::time::sleep(POLL_INTERVAL).await;
289        }
290    }
291
292    fn session_address(&self, address: &str) -> Result<Address> {
293        let address = parse_address(address)?;
294        if address == self.signer.address() || address == self.wallet {
295            return Err(Error::bad_request(
296                "Session signer must differ from the owner and Deposit Wallet",
297            ));
298        }
299
300        Ok(address)
301    }
302
303    async fn signed_request(
304        &self,
305        address: Address,
306        data: Vec<u8>,
307        valid_until: Option<u64>,
308    ) -> Result<SessionRequest> {
309        let nonce = self.relayer.get_wallet_nonce(self.signer.address()).await?;
310        let deadline = U256::from(unix_seconds() + BATCH_DEADLINE_SECS);
311
312        let calls = [DepositWalletCall {
313            target: self.wallet,
314            value: U256::ZERO,
315            data: data.into(),
316        }];
317
318        let signature =
319            self.signer
320                .sign_deposit_wallet_batch(self.wallet, nonce, deadline, &calls)?;
321        Ok(SessionRequest {
322            wallet_address: format!("{:#x}", self.wallet),
323            session_signer_address: format!("{address:#x}"),
324            nonce: nonce.to_string(),
325            deadline: deadline.to_string(),
326            signature,
327            valid_until: valid_until.map(|value| value.to_string()),
328            scopes: valid_until.map(|_| vec!["CLOB"]),
329        })
330    }
331
332    async fn submit(&self, pending: &mut SessionMutation) -> Result<()> {
333        if pending.transaction_id.is_some() {
334            return Ok(());
335        }
336
337        let authorize = pending.valid_until.is_some();
338
339        let path = if authorize {
340            "/v1/session-signers/authorizations"
341        } else {
342            "/v1/session-signers/revocations"
343        };
344
345        for attempt in 0..3 {
346            let previously_attempted = pending.attempted;
347            pending.attempted = true;
348            let result = self
349                .relayer
350                .post_session::<serde_json::Value>(
351                    path,
352                    pending.body.expose_secret(),
353                    &pending.idempotency_key,
354                )
355                .await;
356
357            match result {
358                Ok(response) => {
359                    let (status, transaction_id) = if authorize {
360                        let response: AuthorizationResponse = serde_json::from_value(response)?;
361
362                        if !matches!(
363                            response.status.as_str(),
364                            "SUBMITTED" | "REGISTRY_PENDING" | "REGISTERED"
365                        ) {
366                            pending.rejected = matches!(
367                                response.status.as_str(),
368                                "FAILED" | "SUPERSEDED" | "REPAIR_REQUIRED"
369                            );
370                            return Err(Error::exchange(format!(
371                                "Session authorization status {}",
372                                response.status
373                            )));
374                        }
375
376                        (response.status, response.transaction_id)
377                    } else {
378                        let response: RevocationResponse = serde_json::from_value(response)?;
379                        if !matches!(
380                            response.status.as_str(),
381                            "PENDING" | "FENCED" | "SWEPT" | "CHAIN_SUBMITTED" | "CONFIRMED"
382                        ) {
383                            pending.rejected = response.status == "FAILED";
384                            return Err(Error::exchange(format!(
385                                "Session revocation status {}",
386                                response.status
387                            )));
388                        }
389
390                        (response.status, response.transaction_id)
391                    };
392
393                    if transaction_id.trim().is_empty() {
394                        return Err(Error::decode(format!(
395                            "Session {status} response omitted its transaction ID"
396                        )));
397                    }
398
399                    pending.transaction_id = Some(transaction_id);
400                    return Ok(());
401                }
402                Err(e) if e.is_retryable() && attempt < 2 => {
403                    tokio::time::sleep(POLL_INTERVAL).await;
404                }
405                Err(e) => {
406                    pending.rejected = !previously_attempted && !e.is_submit_outcome_unknown();
407                    return Err(e);
408                }
409            }
410        }
411
412        Err(Error::exchange("Session submission retry budget exhausted"))
413    }
414}
415
416#[derive(Debug, Deserialize)]
417pub(crate) struct SessionKeysResponse {
418    pub wallet: String,
419    pub signers: Vec<PolymarketSessionKey>,
420}
421
422#[derive(Serialize)]
423#[serde(rename_all = "camelCase")]
424struct SessionRequest {
425    wallet_address: String,
426    session_signer_address: String,
427    nonce: String,
428    deadline: String,
429    signature: String,
430    #[serde(skip_serializing_if = "Option::is_none")]
431    valid_until: Option<String>,
432    #[serde(skip_serializing_if = "Option::is_none")]
433    scopes: Option<Vec<&'static str>>,
434}
435
436#[derive(Deserialize)]
437#[serde(rename_all = "camelCase")]
438struct AuthorizationResponse {
439    status: String,
440    transaction_id: String,
441}
442
443#[derive(Deserialize)]
444#[serde(rename_all = "camelCase")]
445struct RevocationResponse {
446    status: String,
447    transaction_id: String,
448    #[serde(rename = "fenced")]
449    _fenced: bool,
450}
451
452#[derive(Debug)]
453struct SessionMutation {
454    address: Address,
455    valid_until: Option<u64>,
456    body: SecretString,
457    idempotency_key: String,
458    transaction_id: Option<String>,
459    attempted: bool,
460    rejected: bool,
461}
462
463impl SessionMutation {
464    fn unresolved(&self, reason: &str) -> Error {
465        Error::exchange(format!(
466            "{reason}; session operation unresolved (idempotency_key={}, transaction_id={}); repeat the same operation on this client",
467            self.idempotency_key,
468            self.transaction_id.as_deref().unwrap_or("unknown"),
469        ))
470    }
471}
472
473fn retryable_read(e: &Error) -> bool {
474    e.is_retryable()
475        || matches!(
476            e,
477            Error::Http {
478                status: 404 | 409,
479                ..
480            }
481        )
482}
483
484alloy::sol! {
485    function authorizeSessionSigner(address sessionSigner, uint256 validUntil);
486    function revokeSessionSigner(address sessionSigner);
487}
488
489fn parse_address(value: &str) -> Result<Address> {
490    if !value.starts_with("0x") || value.len() != 42 {
491        return Err(Error::bad_request("Expected a 0x-prefixed EVM address"));
492    }
493
494    let address = value
495        .parse::<Address>()
496        .map_err(|_| Error::bad_request("Invalid EVM address"))?;
497    if address.is_zero() {
498        return Err(Error::bad_request("EVM address must not be zero"));
499    }
500
501    Ok(address)
502}
503
504fn unix_seconds() -> u64 {
505    get_atomic_clock_realtime().get_time_ns().as_u64() / 1_000_000_000
506}