Skip to main content

nautilus_polymarket/common/
credential.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//! Credential management for the Polymarket adapter.
17
18use std::{
19    fmt::{Debug, Display},
20    str::FromStr,
21};
22
23use alloy::signers::local::PrivateKeySigner;
24use aws_lc_rs::hmac;
25use base64::{Engine, engine::general_purpose::URL_SAFE};
26use nautilus_core::{
27    env::{get_or_env_var, get_or_env_var_opt},
28    hex,
29};
30use ustr::Ustr;
31use zeroize::{Zeroize, ZeroizeOnDrop};
32
33use crate::http::error::{Error, Result};
34
35const API_KEY_VAR: &str = "POLYMARKET_API_KEY";
36const API_SECRET_VAR: &str = "POLYMARKET_API_SECRET";
37const PASSPHRASE_VAR: &str = "POLYMARKET_PASSPHRASE";
38const PRIVATE_KEY_VAR: &str = "POLYMARKET_PK";
39const FUNDER_VAR: &str = "POLYMARKET_FUNDER";
40
41/// Returns `(api_key_var, api_secret_var, passphrase_var, private_key_var, funder_var)`.
42#[must_use]
43pub const fn credential_env_vars() -> (
44    &'static str,
45    &'static str,
46    &'static str,
47    &'static str,
48    &'static str,
49) {
50    (
51        API_KEY_VAR,
52        API_SECRET_VAR,
53        PASSPHRASE_VAR,
54        PRIVATE_KEY_VAR,
55        FUNDER_VAR,
56    )
57}
58
59/// Secure wrapper for an EVM private key, zeroized on drop.
60#[derive(Clone, Zeroize, ZeroizeOnDrop)]
61pub struct EvmPrivateKey {
62    formatted_key: String,
63    raw_bytes: Vec<u8>,
64}
65
66impl EvmPrivateKey {
67    /// Creates a new [`EvmPrivateKey`] from a hex string (with or without `0x` prefix).
68    pub fn new(key: &str) -> Result<Self> {
69        let key = key.trim().to_string();
70        let hex_key = key.strip_prefix("0x").unwrap_or(&key);
71
72        if hex_key.len() != 64 {
73            return Err(Error::bad_request(
74                "EVM private key must be 32 bytes (64 hex chars)",
75            ));
76        }
77
78        if !hex_key.chars().all(|c| c.is_ascii_hexdigit()) {
79            return Err(Error::bad_request("EVM private key must be valid hex"));
80        }
81
82        let normalized = hex_key.to_lowercase();
83        let formatted = format!("0x{normalized}");
84
85        let raw_bytes = hex::decode(&normalized)
86            .map_err(|_| Error::bad_request("Invalid hex in private key"))?;
87
88        if raw_bytes.len() != 32 {
89            return Err(Error::bad_request(
90                "EVM private key must be exactly 32 bytes",
91            ));
92        }
93
94        Ok(Self {
95            formatted_key: formatted,
96            raw_bytes,
97        })
98    }
99
100    pub fn as_hex(&self) -> &str {
101        &self.formatted_key
102    }
103
104    pub fn as_bytes(&self) -> &[u8] {
105        &self.raw_bytes
106    }
107}
108
109impl Debug for EvmPrivateKey {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.write_str("EvmPrivateKey(***)")
112    }
113}
114
115impl Display for EvmPrivateKey {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.write_str("EvmPrivateKey(***)")
118    }
119}
120
121/// L2 API credential with HMAC-SHA256 signing for authenticated requests.
122///
123/// Stores the API key as `Ustr` (interned, used for lookups) and the
124/// decoded secret as `Box<[u8]>` (zeroized on drop). The base64 secret
125/// is decoded once at construction to avoid repeated decoding per request.
126#[derive(Clone)]
127pub struct Credential {
128    api_key: Ustr,
129    secret_bytes: Box<[u8]>,
130    passphrase: String,
131}
132
133impl Credential {
134    /// Creates a new credential. The `api_secret` must be base64-encoded.
135    pub fn new(api_key: &str, api_secret: &str, passphrase: String) -> Result<Self> {
136        // Polymarket API secrets are URL-safe base64 encoded
137        let secret_bytes = URL_SAFE
138            .decode(api_secret)
139            .map_err(|e| Error::auth(format!("Invalid base64 API secret: {e}")))?
140            .into_boxed_slice();
141
142        Ok(Self {
143            api_key: Ustr::from(api_key),
144            secret_bytes,
145            passphrase,
146        })
147    }
148
149    pub fn api_key(&self) -> Ustr {
150        self.api_key
151    }
152
153    pub fn passphrase(&self) -> &str {
154        &self.passphrase
155    }
156
157    /// Returns the raw API secret as a base64-encoded string.
158    ///
159    /// Used for WebSocket user channel authentication which expects the raw
160    /// secret (not an HMAC signature).
161    pub fn api_secret(&self) -> String {
162        URL_SAFE.encode(&*self.secret_bytes)
163    }
164
165    /// Signs a request with HMAC-SHA256 and returns the base64-encoded signature.
166    ///
167    /// Message format: `{timestamp}{method}{request_path}{body}`
168    pub fn sign(&self, timestamp: &str, method: &str, request_path: &str, body: &str) -> String {
169        let message = format!("{timestamp}{method}{request_path}{body}");
170        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.secret_bytes);
171        let tag = hmac::sign(&key, message.as_bytes());
172        URL_SAFE.encode(tag.as_ref())
173    }
174
175    /// Resolves from provided values, falling back to environment variables.
176    pub fn resolve(
177        api_key: Option<String>,
178        api_secret: Option<String>,
179        passphrase: Option<String>,
180    ) -> Result<Self> {
181        let key = get_or_env_var(api_key.filter(|s| !s.trim().is_empty()), API_KEY_VAR).map_err(
182            |_| Error::bad_request(format!("{API_KEY_VAR} environment variable is not set")),
183        )?;
184
185        let secret = get_or_env_var(api_secret.filter(|s| !s.trim().is_empty()), API_SECRET_VAR)
186            .map_err(|_| {
187                Error::bad_request(format!("{API_SECRET_VAR} environment variable is not set"))
188            })?;
189
190        let pass = get_or_env_var(passphrase.filter(|s| !s.trim().is_empty()), PASSPHRASE_VAR)
191            .map_err(|_| {
192                Error::bad_request(format!("{PASSPHRASE_VAR} environment variable is not set"))
193            })?;
194
195        Self::new(&key, &secret, pass)
196    }
197
198    pub fn from_env() -> Result<Self> {
199        Self::resolve(None, None, None)
200    }
201}
202
203impl Drop for Credential {
204    fn drop(&mut self) {
205        self.secret_bytes.zeroize();
206        self.passphrase.zeroize();
207    }
208}
209
210impl Debug for Credential {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        f.debug_struct(stringify!(Credential))
213            .field(
214                "api_key",
215                &format!("{}...", &self.api_key.as_str()[..8.min(self.api_key.len())]),
216            )
217            .field("secret_bytes", &"***")
218            .field("passphrase", &"***")
219            .finish()
220    }
221}
222
223/// Complete secrets configuration for Polymarket.
224///
225/// Ethereum address derived from the private key (lowercased with `0x` prefix).
226#[derive(Clone)]
227pub struct Secrets {
228    pub private_key: EvmPrivateKey,
229    pub credential: Credential,
230    pub funder: Option<String>,
231    pub address: String,
232}
233
234impl Debug for Secrets {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        f.debug_struct(stringify!(Secrets))
237            .field("private_key", &self.private_key)
238            .field("credential", &self.credential)
239            .field("address", &self.address)
240            .field(
241                "funder",
242                &self.funder.as_deref().map(|s| {
243                    if s.len() > 10 {
244                        format!("{}...{}", &s[..6], &s[s.len() - 4..])
245                    } else {
246                        s.to_string()
247                    }
248                }),
249            )
250            .finish()
251    }
252}
253
254impl Secrets {
255    /// Resolves from provided values, falling back to environment variables.
256    pub fn resolve(
257        private_key: Option<&str>,
258        api_key: Option<String>,
259        api_secret: Option<String>,
260        passphrase: Option<String>,
261        funder: Option<String>,
262    ) -> Result<Self> {
263        let pk_str = get_or_env_var(
264            private_key
265                .filter(|s| !s.trim().is_empty())
266                .map(String::from),
267            PRIVATE_KEY_VAR,
268        )
269        .map_err(|_| {
270            Error::bad_request(format!("{PRIVATE_KEY_VAR} environment variable is not set"))
271        })?;
272
273        let private_key = EvmPrivateKey::new(&pk_str)?;
274        let credential = Credential::resolve(api_key, api_secret, passphrase)?;
275
276        let funder = get_or_env_var_opt(funder.filter(|s| !s.trim().is_empty()), FUNDER_VAR)
277            .filter(|s| !s.trim().is_empty());
278
279        let key_hex = private_key
280            .as_hex()
281            .strip_prefix("0x")
282            .unwrap_or(private_key.as_hex());
283        let signer = PrivateKeySigner::from_str(key_hex)
284            .map_err(|e| Error::bad_request(format!("Failed to derive address: {e}")))?;
285        let address = format!("{:#x}", signer.address());
286
287        log::info!(
288            "Polymarket credentials resolved: address={}, funder={:?}, api_key={}...)",
289            address,
290            funder.as_deref().map(|s| &s[..10.min(s.len())]),
291            &credential.api_key()[..8]
292        );
293
294        Ok(Self {
295            private_key,
296            credential,
297            funder,
298            address,
299        })
300    }
301
302    pub fn from_env() -> Result<Self> {
303        Self::resolve(None, None, None, None, None)
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use rstest::rstest;
310
311    use super::*;
312
313    const TEST_PRIVATE_KEY: &str =
314        "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
315
316    fn test_secret_b64() -> String {
317        URL_SAFE.encode(b"test_secret_key_32bytes_pad12345")
318    }
319
320    #[rstest]
321    fn test_evm_private_key_with_0x_prefix() {
322        let key = EvmPrivateKey::new(TEST_PRIVATE_KEY).unwrap();
323        assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
324        assert_eq!(key.as_bytes().len(), 32);
325    }
326
327    #[rstest]
328    fn test_evm_private_key_without_0x_prefix() {
329        let key = EvmPrivateKey::new(&TEST_PRIVATE_KEY[2..]).unwrap();
330        assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
331    }
332
333    #[rstest]
334    fn test_evm_private_key_invalid_length() {
335        assert!(EvmPrivateKey::new("0x123").is_err());
336    }
337
338    #[rstest]
339    fn test_evm_private_key_invalid_hex() {
340        let bad = "0x123g567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
341        assert!(EvmPrivateKey::new(bad).is_err());
342    }
343
344    #[rstest]
345    fn test_evm_private_key_debug_redacts() {
346        let key = EvmPrivateKey::new(TEST_PRIVATE_KEY).unwrap();
347        let debug = format!("{key:?}");
348        assert_eq!(debug, "EvmPrivateKey(***)");
349        assert!(!debug.contains("1234"));
350    }
351
352    #[rstest]
353    fn test_credential_creation() {
354        let cred =
355            Credential::new("test_api_key", &test_secret_b64(), "test_pass".to_string()).unwrap();
356        assert_eq!(cred.api_key().as_str(), "test_api_key");
357        assert_eq!(cred.passphrase(), "test_pass");
358    }
359
360    #[rstest]
361    fn test_credential_invalid_base64_secret() {
362        let result = Credential::new("key", "not-valid-base64!!!", "pass".to_string());
363        assert!(result.is_err());
364    }
365
366    #[rstest]
367    fn test_credential_sign_produces_base64() {
368        let cred =
369            Credential::new("key", &URL_SAFE.encode(b"test_secret"), "pass".to_string()).unwrap();
370
371        let sig = cred.sign("1234567890", "GET", "/order", "");
372        assert!(URL_SAFE.decode(&sig).is_ok());
373    }
374
375    #[rstest]
376    fn test_credential_sign_deterministic() {
377        let cred = Credential::new(
378            "key",
379            &URL_SAFE.encode(b"deterministic_test"),
380            "pass".to_string(),
381        )
382        .unwrap();
383
384        let sig1 = cred.sign("1000", "POST", "/order", r#"{"price":"0.5"}"#);
385        let sig2 = cred.sign("1000", "POST", "/order", r#"{"price":"0.5"}"#);
386        assert_eq!(sig1, sig2);
387    }
388
389    #[rstest]
390    fn test_credential_sign_different_timestamps() {
391        let cred =
392            Credential::new("key", &URL_SAFE.encode(b"test_key"), "pass".to_string()).unwrap();
393
394        let sig1 = cred.sign("1000", "GET", "/order", "");
395        let sig2 = cred.sign("1001", "GET", "/order", "");
396        assert_ne!(sig1, sig2);
397    }
398
399    #[rstest]
400    fn test_credential_sign_different_methods() {
401        let cred =
402            Credential::new("key", &URL_SAFE.encode(b"test_key"), "pass".to_string()).unwrap();
403
404        let sig1 = cred.sign("1000", "GET", "/order", "");
405        let sig2 = cred.sign("1000", "POST", "/order", "");
406        assert_ne!(sig1, sig2);
407    }
408
409    #[rstest]
410    fn test_credential_sign_different_paths() {
411        let cred =
412            Credential::new("key", &URL_SAFE.encode(b"test_key"), "pass".to_string()).unwrap();
413
414        let sig1 = cred.sign("1000", "GET", "/order", "");
415        let sig2 = cred.sign("1000", "GET", "/trades", "");
416        assert_ne!(sig1, sig2);
417    }
418
419    #[rstest]
420    fn test_credential_sign_different_bodies() {
421        let cred =
422            Credential::new("key", &URL_SAFE.encode(b"test_key"), "pass".to_string()).unwrap();
423
424        let sig1 = cred.sign("1000", "POST", "/order", r#"{"a":1}"#);
425        let sig2 = cred.sign("1000", "POST", "/order", r#"{"a":2}"#);
426        assert_ne!(sig1, sig2);
427    }
428
429    #[rstest]
430    fn test_credential_sign_empty_body() {
431        let cred =
432            Credential::new("key", &URL_SAFE.encode(b"test_key"), "pass".to_string()).unwrap();
433
434        let sig1 = cred.sign("1000", "GET", "/order", "");
435        let sig2 = cred.sign("1000", "GET", "/order", "{}");
436        assert_ne!(sig1, sig2);
437    }
438
439    // Test vectors from Polymarket SDK (rs-clob-client/src/auth.rs)
440    const SDK_SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
441    const SDK_PASSPHRASE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
442
443    #[rstest]
444    fn test_credential_sign_matches_sdk_l2_vector() {
445        let cred = Credential::new(
446            "00000000-0000-0000-0000-000000000000",
447            SDK_SECRET,
448            SDK_PASSPHRASE.to_string(),
449        )
450        .unwrap();
451
452        // SDK test: timestamp=1, GET, "/", empty body
453        let sig = cred.sign("1", "GET", "/", "");
454        assert_eq!(sig, "eHaylCwqRSOa2LFD77Nt_SaTpbsxzN8eTEI3LryhEj4=");
455    }
456
457    #[rstest]
458    fn test_credential_sign_matches_sdk_hmac_vector() {
459        let cred = Credential::new("key", SDK_SECRET, "pass".to_string()).unwrap();
460
461        // SDK test: raw message "1000000test-sign/orders{"hash":"0x123"}"
462        let sig = cred.sign("1000000", "test-sign", "/orders", r#"{"hash":"0x123"}"#);
463        assert_eq!(sig, "4gJVbox-R6XlDK4nlaicig0_ANVL1qdcahiL8CXfXLM=");
464    }
465
466    #[rstest]
467    fn test_credential_debug_redacts_secret() {
468        let cred = Credential::new(
469            "my_api_key_12345678",
470            &test_secret_b64(),
471            "my_passphrase".to_string(),
472        )
473        .unwrap();
474
475        let debug = format!("{cred:?}");
476        assert!(debug.contains("my_api_k..."));
477        assert!(debug.contains("***"));
478        assert!(!debug.contains("test_secret"));
479        assert!(!debug.contains("my_passphrase"));
480    }
481}