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> {
161 self.mutate(address, true).await?.ok_or_else(|| {
162 Error::decode("Session authorization completed without registry metadata")
163 })
164 }
165
166 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 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}