1use 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
37const 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#[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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
63pub struct PolymarketSessionKey {
64 pub address: String,
65 pub scopes: Vec<String>,
66 pub valid_until: u64,
68}
69
70#[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 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 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 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 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 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}