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 and
125/// HMAC key are initialized once to avoid repeated setup per request.
126/// `aws-lc-rs` cleanses the native HMAC context when the key is dropped.
127#[derive(Clone)]
128pub struct Credential {
129    api_key: Ustr,
130    secret_bytes: Box<[u8]>,
131    signing_key: hmac::Key,
132    passphrase: String,
133}
134
135impl Credential {
136    /// Creates a new credential. The `api_secret` must be base64-encoded.
137    pub fn new(api_key: &str, api_secret: &str, passphrase: String) -> Result<Self> {
138        // Polymarket API secrets are URL-safe base64 encoded
139        let secret_bytes = URL_SAFE
140            .decode(api_secret)
141            .map_err(|e| Error::auth(format!("Invalid base64 API secret: {e}")))?
142            .into_boxed_slice();
143        let signing_key = hmac::Key::new(hmac::HMAC_SHA256, &secret_bytes);
144
145        Ok(Self {
146            api_key: Ustr::from(api_key),
147            secret_bytes,
148            signing_key,
149            passphrase,
150        })
151    }
152
153    pub fn api_key(&self) -> Ustr {
154        self.api_key
155    }
156
157    pub fn passphrase(&self) -> &str {
158        &self.passphrase
159    }
160
161    /// Returns the raw API secret as a base64-encoded string.
162    ///
163    /// Used for WebSocket user channel authentication which expects the raw
164    /// secret (not an HMAC signature).
165    pub fn api_secret(&self) -> String {
166        URL_SAFE.encode(&*self.secret_bytes)
167    }
168
169    /// Signs a request with HMAC-SHA256 and returns the base64-encoded signature.
170    ///
171    /// Message format: `{timestamp}{method}{request_path}{body}`
172    pub fn sign(&self, timestamp: &str, method: &str, request_path: &str, body: &str) -> String {
173        let mut context = hmac::Context::with_key(&self.signing_key);
174        context.update(timestamp.as_bytes());
175        context.update(method.as_bytes());
176        context.update(request_path.as_bytes());
177        context.update(body.as_bytes());
178        let tag = context.sign();
179        URL_SAFE.encode(tag.as_ref())
180    }
181
182    /// Resolves from provided values, falling back to environment variables.
183    pub fn resolve(
184        api_key: Option<String>,
185        api_secret: Option<String>,
186        passphrase: Option<String>,
187    ) -> Result<Self> {
188        let key = get_or_env_var(api_key.filter(|s| !s.trim().is_empty()), API_KEY_VAR).map_err(
189            |_| Error::bad_request(format!("{API_KEY_VAR} environment variable is not set")),
190        )?;
191
192        let secret = get_or_env_var(api_secret.filter(|s| !s.trim().is_empty()), API_SECRET_VAR)
193            .map_err(|_| {
194                Error::bad_request(format!("{API_SECRET_VAR} environment variable is not set"))
195            })?;
196
197        let pass = get_or_env_var(passphrase.filter(|s| !s.trim().is_empty()), PASSPHRASE_VAR)
198            .map_err(|_| {
199                Error::bad_request(format!("{PASSPHRASE_VAR} environment variable is not set"))
200            })?;
201
202        Self::new(&key, &secret, pass)
203    }
204
205    pub fn from_env() -> Result<Self> {
206        Self::resolve(None, None, None)
207    }
208}
209
210impl Drop for Credential {
211    fn drop(&mut self) {
212        self.secret_bytes.zeroize();
213        self.passphrase.zeroize();
214    }
215}
216
217impl Debug for Credential {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        f.debug_struct(stringify!(Credential))
220            .field(
221                "api_key",
222                &format!("{}...", &self.api_key.as_str()[..8.min(self.api_key.len())]),
223            )
224            .field("secret_bytes", &"***")
225            .field("passphrase", &"***")
226            .finish()
227    }
228}
229
230/// Complete secrets configuration for Polymarket.
231///
232/// Ethereum address derived from the private key (lowercased with `0x` prefix).
233#[derive(Clone)]
234pub struct Secrets {
235    pub private_key: EvmPrivateKey,
236    pub credential: Credential,
237    pub funder: Option<String>,
238    pub address: String,
239}
240
241impl Debug for Secrets {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        f.debug_struct(stringify!(Secrets))
244            .field("private_key", &self.private_key)
245            .field("credential", &self.credential)
246            .field("address", &self.address)
247            .field(
248                "funder",
249                &self.funder.as_deref().map(|s| {
250                    if s.len() > 10 {
251                        format!("{}...{}", &s[..6], &s[s.len() - 4..])
252                    } else {
253                        s.to_string()
254                    }
255                }),
256            )
257            .finish()
258    }
259}
260
261impl Secrets {
262    /// Resolves from provided values, falling back to environment variables.
263    pub fn resolve(
264        private_key: Option<&str>,
265        api_key: Option<String>,
266        api_secret: Option<String>,
267        passphrase: Option<String>,
268        funder: Option<String>,
269    ) -> Result<Self> {
270        let pk_str = get_or_env_var(
271            private_key
272                .filter(|s| !s.trim().is_empty())
273                .map(String::from),
274            PRIVATE_KEY_VAR,
275        )
276        .map_err(|_| {
277            Error::bad_request(format!("{PRIVATE_KEY_VAR} environment variable is not set"))
278        })?;
279
280        let private_key = EvmPrivateKey::new(&pk_str)?;
281        let credential = Credential::resolve(api_key, api_secret, passphrase)?;
282
283        let funder = get_or_env_var_opt(funder.filter(|s| !s.trim().is_empty()), FUNDER_VAR)
284            .filter(|s| !s.trim().is_empty());
285
286        let key_hex = private_key
287            .as_hex()
288            .strip_prefix("0x")
289            .unwrap_or(private_key.as_hex());
290        let signer = PrivateKeySigner::from_str(key_hex)
291            .map_err(|e| Error::bad_request(format!("Failed to derive address: {e}")))?;
292        let address = format!("{:#x}", signer.address());
293
294        log::debug!(
295            "Polymarket credentials resolved: address={}, funder={:?}, api_key={}...)",
296            address,
297            funder.as_deref().map(|s| &s[..10.min(s.len())]),
298            &credential.api_key()[..8]
299        );
300
301        Ok(Self {
302            private_key,
303            credential,
304            funder,
305            address,
306        })
307    }
308
309    pub fn from_env() -> Result<Self> {
310        Self::resolve(None, None, None, None, None)
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use rstest::rstest;
317
318    use super::*;
319
320    const TEST_PRIVATE_KEY: &str =
321        "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
322
323    fn test_secret_b64() -> String {
324        URL_SAFE.encode(b"test_secret_key_32bytes_pad12345")
325    }
326
327    #[rstest]
328    fn test_evm_private_key_with_0x_prefix() {
329        let key = EvmPrivateKey::new(TEST_PRIVATE_KEY).unwrap();
330        assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
331        assert_eq!(key.as_bytes().len(), 32);
332    }
333
334    #[rstest]
335    fn test_evm_private_key_without_0x_prefix() {
336        let key = EvmPrivateKey::new(&TEST_PRIVATE_KEY[2..]).unwrap();
337        assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
338    }
339
340    #[rstest]
341    fn test_evm_private_key_invalid_length() {
342        assert!(EvmPrivateKey::new("0x123").is_err());
343    }
344
345    #[rstest]
346    fn test_evm_private_key_invalid_hex() {
347        let bad = "0x123g567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
348        assert!(EvmPrivateKey::new(bad).is_err());
349    }
350
351    #[rstest]
352    fn test_evm_private_key_debug_redacts() {
353        let key = EvmPrivateKey::new(TEST_PRIVATE_KEY).unwrap();
354        let debug = format!("{key:?}");
355        assert_eq!(debug, "EvmPrivateKey(***)");
356        assert!(!debug.contains("1234"));
357    }
358
359    #[rstest]
360    fn test_credential_creation() {
361        let cred =
362            Credential::new("test_api_key", &test_secret_b64(), "test_pass".to_string()).unwrap();
363        assert_eq!(cred.api_key().as_str(), "test_api_key");
364        assert_eq!(cred.passphrase(), "test_pass");
365    }
366
367    #[rstest]
368    fn test_credential_invalid_base64_secret() {
369        let result = Credential::new("key", "not-valid-base64!!!", "pass".to_string());
370        assert!(result.is_err());
371    }
372
373    #[rstest]
374    fn test_credential_sign_produces_base64() {
375        let cred =
376            Credential::new("key", &URL_SAFE.encode(b"test_secret"), "pass".to_string()).unwrap();
377
378        let sig = cred.sign("1234567890", "GET", "/order", "");
379        assert!(URL_SAFE.decode(&sig).is_ok());
380    }
381
382    #[rstest]
383    fn test_credential_sign_deterministic() {
384        let cred = Credential::new(
385            "key",
386            &URL_SAFE.encode(b"deterministic_test"),
387            "pass".to_string(),
388        )
389        .unwrap();
390
391        let sig1 = cred.sign("1000", "POST", "/order", r#"{"price":"0.5"}"#);
392        let sig2 = cred.sign("1000", "POST", "/order", r#"{"price":"0.5"}"#);
393        assert_eq!(sig1, sig2);
394    }
395
396    #[rstest]
397    fn test_credential_sign_different_timestamps() {
398        let cred =
399            Credential::new("key", &URL_SAFE.encode(b"test_key"), "pass".to_string()).unwrap();
400
401        let sig1 = cred.sign("1000", "GET", "/order", "");
402        let sig2 = cred.sign("1001", "GET", "/order", "");
403        assert_ne!(sig1, sig2);
404    }
405
406    #[rstest]
407    fn test_credential_sign_different_methods() {
408        let cred =
409            Credential::new("key", &URL_SAFE.encode(b"test_key"), "pass".to_string()).unwrap();
410
411        let sig1 = cred.sign("1000", "GET", "/order", "");
412        let sig2 = cred.sign("1000", "POST", "/order", "");
413        assert_ne!(sig1, sig2);
414    }
415
416    #[rstest]
417    fn test_credential_sign_different_paths() {
418        let cred =
419            Credential::new("key", &URL_SAFE.encode(b"test_key"), "pass".to_string()).unwrap();
420
421        let sig1 = cred.sign("1000", "GET", "/order", "");
422        let sig2 = cred.sign("1000", "GET", "/trades", "");
423        assert_ne!(sig1, sig2);
424    }
425
426    #[rstest]
427    fn test_credential_sign_different_bodies() {
428        let cred =
429            Credential::new("key", &URL_SAFE.encode(b"test_key"), "pass".to_string()).unwrap();
430
431        let sig1 = cred.sign("1000", "POST", "/order", r#"{"a":1}"#);
432        let sig2 = cred.sign("1000", "POST", "/order", r#"{"a":2}"#);
433        assert_ne!(sig1, sig2);
434    }
435
436    #[rstest]
437    fn test_credential_sign_empty_body() {
438        let cred =
439            Credential::new("key", &URL_SAFE.encode(b"test_key"), "pass".to_string()).unwrap();
440
441        let sig1 = cred.sign("1000", "GET", "/order", "");
442        let sig2 = cred.sign("1000", "GET", "/order", "{}");
443        assert_ne!(sig1, sig2);
444    }
445
446    // Test vectors from Polymarket SDK (rs-clob-client/src/auth.rs)
447    const SDK_SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
448    const SDK_PASSPHRASE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
449
450    #[rstest]
451    fn test_credential_sign_matches_sdk_l2_vector() {
452        let cred = Credential::new(
453            "00000000-0000-0000-0000-000000000000",
454            SDK_SECRET,
455            SDK_PASSPHRASE.to_string(),
456        )
457        .unwrap();
458
459        // SDK test: timestamp=1, GET, "/", empty body
460        let sig = cred.sign("1", "GET", "/", "");
461        assert_eq!(sig, "eHaylCwqRSOa2LFD77Nt_SaTpbsxzN8eTEI3LryhEj4=");
462    }
463
464    #[rstest]
465    fn test_credential_sign_matches_sdk_hmac_vector() {
466        let cred = Credential::new("key", SDK_SECRET, "pass".to_string()).unwrap();
467
468        // SDK test: raw message "1000000test-sign/orders{"hash":"0x123"}"
469        let sig = cred.sign("1000000", "test-sign", "/orders", r#"{"hash":"0x123"}"#);
470        assert_eq!(sig, "4gJVbox-R6XlDK4nlaicig0_ANVL1qdcahiL8CXfXLM=");
471    }
472
473    #[rstest]
474    fn test_credential_debug_redacts_secret() {
475        let cred = Credential::new(
476            "my_api_key_12345678",
477            &test_secret_b64(),
478            "my_passphrase".to_string(),
479        )
480        .unwrap();
481
482        let debug = format!("{cred:?}");
483        assert!(debug.contains("my_api_k..."));
484        assert!(debug.contains("***"));
485        assert!(!debug.contains("test_secret"));
486        assert!(!debug.contains("my_passphrase"));
487    }
488}