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 alloy_primitives::Address;
25use aws_lc_rs::hmac;
26use base64::{Engine, engine::general_purpose::URL_SAFE};
27use nautilus_core::{
28    env::{get_or_env_var, get_or_env_var_opt},
29    hex,
30    string::secret::{REDACTED, SecretString, mask_api_key},
31};
32use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
33
34use crate::http::error::{Error, Result};
35
36const API_KEY_VAR: &str = "POLYMARKET_API_KEY";
37const API_SECRET_VAR: &str = "POLYMARKET_API_SECRET";
38const PASSPHRASE_VAR: &str = "POLYMARKET_PASSPHRASE";
39const PRIVATE_KEY_VAR: &str = "POLYMARKET_PK";
40const FUNDER_VAR: &str = "POLYMARKET_FUNDER";
41const RELAYER_API_KEY_VAR: &str = "POLYMARKET_RELAYER_API_KEY";
42const RELAYER_SIGNER_ADDRESS_VAR: &str = "POLYMARKET_RELAYER_SIGNER_ADDRESS";
43
44/// Returns `(api_key_var, api_secret_var, passphrase_var, private_key_var, funder_var)`.
45#[must_use]
46pub const fn credential_env_vars() -> (
47    &'static str,
48    &'static str,
49    &'static str,
50    &'static str,
51    &'static str,
52) {
53    (
54        API_KEY_VAR,
55        API_SECRET_VAR,
56        PASSPHRASE_VAR,
57        PRIVATE_KEY_VAR,
58        FUNDER_VAR,
59    )
60}
61
62/// Returns `(relayer_api_key_var, relayer_signer_address_var)`.
63#[must_use]
64pub const fn relayer_credential_env_vars() -> (&'static str, &'static str) {
65    (RELAYER_API_KEY_VAR, RELAYER_SIGNER_ADDRESS_VAR)
66}
67
68/// Secure wrapper for an EVM private key, zeroized on drop.
69#[derive(Clone, Zeroize, ZeroizeOnDrop)]
70pub struct EvmPrivateKey {
71    formatted_key: String,
72    raw_bytes: Vec<u8>,
73}
74
75impl EvmPrivateKey {
76    /// Creates a new [`EvmPrivateKey`] from a hex string (with or without `0x` prefix).
77    pub fn new(key: &str) -> Result<Self> {
78        let key = Zeroizing::new(key.trim().to_string());
79        let hex_key = key.strip_prefix("0x").unwrap_or(&key);
80
81        if hex_key.len() != 64 {
82            return Err(Error::bad_request(
83                "EVM private key must be 32 bytes (64 hex chars)",
84            ));
85        }
86
87        if !hex_key.chars().all(|c| c.is_ascii_hexdigit()) {
88            return Err(Error::bad_request("EVM private key must be valid hex"));
89        }
90
91        let normalized = Zeroizing::new(hex_key.to_lowercase());
92        let formatted = format!("0x{}", normalized.as_str());
93
94        let raw_bytes = hex::decode(&normalized)
95            .map_err(|_| Error::bad_request("Invalid hex in private key"))?;
96
97        if raw_bytes.len() != 32 {
98            return Err(Error::bad_request(
99                "EVM private key must be exactly 32 bytes",
100            ));
101        }
102
103        Ok(Self {
104            formatted_key: formatted,
105            raw_bytes,
106        })
107    }
108
109    pub fn as_hex(&self) -> &str {
110        &self.formatted_key
111    }
112
113    pub fn as_bytes(&self) -> &[u8] {
114        &self.raw_bytes
115    }
116}
117
118impl Debug for EvmPrivateKey {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        write!(f, "EvmPrivateKey({REDACTED})")
121    }
122}
123
124impl Display for EvmPrivateKey {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        write!(f, "EvmPrivateKey({REDACTED})")
127    }
128}
129
130/// L2 API credential with HMAC-SHA256 signing for authenticated requests.
131///
132/// Stores the API key as owned text and the decoded secret as `Box<[u8]>`,
133/// both zeroized on drop. The base64 secret and
134/// HMAC key are initialized once to avoid repeated setup per request.
135/// `aws-lc-rs` cleanses the native HMAC context when the key is dropped.
136#[derive(Clone)]
137pub struct Credential {
138    api_key: Box<str>,
139    secret_bytes: Box<[u8]>,
140    signing_key: hmac::Key,
141    passphrase: String,
142}
143
144impl Credential {
145    /// Creates a new credential. The `api_secret` must be base64-encoded.
146    #[expect(
147        clippy::needless_pass_by_value,
148        reason = "ownership ensures the encoded secret is zeroized immediately after decoding"
149    )]
150    pub fn new(
151        api_key: SecretString,
152        api_secret: SecretString,
153        passphrase: SecretString,
154    ) -> Result<Self> {
155        // Polymarket API secrets are URL-safe base64 encoded
156        let secret_bytes = URL_SAFE
157            .decode(api_secret.expose_secret())
158            .map_err(|e| Error::auth(format!("Invalid base64 API secret: {e}")))?
159            .into_boxed_slice();
160        let signing_key = hmac::Key::new(hmac::HMAC_SHA256, &secret_bytes);
161
162        Ok(Self {
163            api_key: api_key.into_inner().into_boxed_str(),
164            secret_bytes,
165            signing_key,
166            passphrase: passphrase.into_inner(),
167        })
168    }
169
170    pub(crate) fn api_key_str(&self) -> &str {
171        &self.api_key
172    }
173
174    pub fn passphrase(&self) -> &str {
175        &self.passphrase
176    }
177
178    /// Returns the raw API secret as a base64-encoded string.
179    ///
180    /// Used for WebSocket user channel authentication which expects the raw
181    /// secret (not an HMAC signature).
182    pub fn api_secret(&self) -> SecretString {
183        URL_SAFE.encode(&*self.secret_bytes).into()
184    }
185
186    /// Signs a request with HMAC-SHA256 and returns the base64-encoded signature.
187    ///
188    /// Message format: `{timestamp}{method}{request_path}{body}`
189    pub fn sign(&self, timestamp: &str, method: &str, request_path: &str, body: &str) -> String {
190        let mut context = hmac::Context::with_key(&self.signing_key);
191        context.update(timestamp.as_bytes());
192        context.update(method.as_bytes());
193        context.update(request_path.as_bytes());
194        context.update(body.as_bytes());
195        let tag = context.sign();
196        URL_SAFE.encode(tag.as_ref())
197    }
198
199    /// Resolves from provided values, falling back to environment variables.
200    pub fn resolve(
201        api_key: Option<SecretString>,
202        api_secret: Option<SecretString>,
203        passphrase: Option<SecretString>,
204    ) -> Result<Self> {
205        let key = resolve_secret(api_key, API_KEY_VAR)?;
206        let secret = resolve_secret(api_secret, API_SECRET_VAR)?;
207        let pass = resolve_secret(passphrase, PASSPHRASE_VAR)?;
208
209        Self::new(key, secret, pass)
210    }
211
212    pub fn from_env() -> Result<Self> {
213        Self::resolve(None, None, None)
214    }
215}
216
217/// Relayer API key used to authorize gasless Deposit Wallet submissions.
218#[derive(Clone)]
219pub struct RelayerApiKey {
220    key: SecretString,
221    address: String,
222}
223
224impl RelayerApiKey {
225    /// Creates a Relayer API key from the key value and signer address.
226    ///
227    /// # Errors
228    ///
229    /// Returns an error if the key is empty or `address` is not a valid EVM address.
230    pub fn new(key: SecretString, address: &str) -> Result<Self> {
231        if key.expose_secret().trim().is_empty() {
232            return Err(Error::bad_request("Relayer API key must not be empty"));
233        }
234
235        let parsed = Address::from_str(address.trim())
236            .map_err(|e| Error::bad_request(format!("Invalid Relayer API key address: {e}")))?;
237        Ok(Self {
238            key,
239            address: format!("{parsed:#x}"),
240        })
241    }
242
243    #[must_use]
244    pub fn key(&self) -> &str {
245        self.key.expose_secret()
246    }
247
248    #[must_use]
249    pub fn address(&self) -> &str {
250        &self.address
251    }
252
253    /// Resolves from provided values, falling back to environment variables.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if either value is missing or invalid.
258    pub fn resolve(key: Option<SecretString>, address: Option<String>) -> Result<Self> {
259        let key = resolve_secret(key, RELAYER_API_KEY_VAR)?;
260
261        let address = match address.filter(|value| !value.trim().is_empty()) {
262            Some(address) => address,
263            None => get_or_env_var(None, RELAYER_SIGNER_ADDRESS_VAR).map_err(|_| {
264                Error::bad_request(format!(
265                    "{RELAYER_SIGNER_ADDRESS_VAR} environment variable is not set"
266                ))
267            })?,
268        };
269
270        Self::new(key, &address)
271    }
272
273    /// Resolves Relayer credentials from the environment.
274    ///
275    /// # Errors
276    ///
277    /// Returns an error if either environment variable is missing or invalid.
278    pub fn from_env() -> Result<Self> {
279        Self::resolve(None, None)
280    }
281}
282
283impl Debug for RelayerApiKey {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        f.debug_struct(stringify!(RelayerApiKey))
286            .field("key", &REDACTED)
287            .field("address", &self.address)
288            .finish()
289    }
290}
291
292impl Drop for Credential {
293    fn drop(&mut self) {
294        self.api_key.zeroize();
295        self.secret_bytes.zeroize();
296        self.passphrase.zeroize();
297    }
298}
299
300impl Debug for Credential {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        f.debug_struct(stringify!(Credential))
303            .field("api_key", &REDACTED)
304            .field("secret_bytes", &REDACTED)
305            .field("passphrase", &REDACTED)
306            .finish()
307    }
308}
309
310/// Complete secrets configuration for Polymarket.
311///
312/// Ethereum address derived from the private key (lowercased with `0x` prefix).
313#[derive(Clone)]
314pub struct Secrets {
315    pub private_key: EvmPrivateKey,
316    pub credential: Credential,
317    pub funder: Option<String>,
318    pub address: String,
319}
320
321impl Debug for Secrets {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        f.debug_struct(stringify!(Secrets))
324            .field("private_key", &self.private_key)
325            .field("credential", &self.credential)
326            .field("address", &self.address)
327            .field(
328                "funder",
329                &self.funder.as_deref().map(|s| {
330                    if s.len() > 10 {
331                        format!("{}...{}", &s[..6], &s[s.len() - 4..])
332                    } else {
333                        s.to_string()
334                    }
335                }),
336            )
337            .finish()
338    }
339}
340
341impl Secrets {
342    /// Resolves from provided values, falling back to environment variables.
343    pub fn resolve(
344        private_key: Option<SecretString>,
345        api_key: Option<SecretString>,
346        api_secret: Option<SecretString>,
347        passphrase: Option<SecretString>,
348        funder: Option<String>,
349    ) -> Result<Self> {
350        let pk_str = resolve_secret(private_key, PRIVATE_KEY_VAR)?;
351
352        let private_key = EvmPrivateKey::new(pk_str.expose_secret())?;
353        let credential = Credential::resolve(api_key, api_secret, passphrase)?;
354
355        let funder = get_or_env_var_opt(funder.filter(|s| !s.trim().is_empty()), FUNDER_VAR)
356            .filter(|s| !s.trim().is_empty());
357
358        let key_hex = private_key
359            .as_hex()
360            .strip_prefix("0x")
361            .unwrap_or(private_key.as_hex());
362        let signer = PrivateKeySigner::from_str(key_hex)
363            .map_err(|e| Error::bad_request(format!("Failed to derive address: {e}")))?;
364        let address = format!("{:#x}", signer.address());
365
366        log::debug!(
367            "Polymarket credentials resolved: address={}, funder={:?}, api_key={}",
368            address,
369            funder.as_deref().map(|s| &s[..10.min(s.len())]),
370            mask_api_key(credential.api_key_str()),
371        );
372
373        Ok(Self {
374            private_key,
375            credential,
376            funder,
377            address,
378        })
379    }
380
381    pub fn from_env() -> Result<Self> {
382        Self::resolve(None, None, None, None, None)
383    }
384}
385
386fn resolve_secret(value: Option<SecretString>, env_var: &str) -> Result<SecretString> {
387    if let Some(value) = value.filter(|value| !value.expose_secret().trim().is_empty()) {
388        return Ok(value);
389    }
390
391    get_or_env_var(None, env_var)
392        .map(SecretString::from)
393        .map_err(|_| Error::bad_request(format!("{env_var} environment variable is not set")))
394}
395
396#[cfg(test)]
397mod tests {
398    use rstest::rstest;
399
400    use super::*;
401
402    const TEST_PRIVATE_KEY: &str =
403        "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
404
405    fn test_secret_b64() -> String {
406        URL_SAFE.encode(b"test_secret_key_32bytes_pad12345")
407    }
408
409    #[rstest]
410    fn test_evm_private_key_with_0x_prefix() {
411        let key = EvmPrivateKey::new(TEST_PRIVATE_KEY).unwrap();
412        assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
413        assert_eq!(key.as_bytes().len(), 32);
414    }
415
416    #[rstest]
417    fn test_evm_private_key_without_0x_prefix() {
418        let key = EvmPrivateKey::new(&TEST_PRIVATE_KEY[2..]).unwrap();
419        assert_eq!(key.as_hex(), TEST_PRIVATE_KEY);
420    }
421
422    #[rstest]
423    fn test_evm_private_key_invalid_length() {
424        assert!(EvmPrivateKey::new("0x123").is_err());
425    }
426
427    #[rstest]
428    fn test_evm_private_key_invalid_hex() {
429        let bad = "0x123g567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
430        assert!(EvmPrivateKey::new(bad).is_err());
431    }
432
433    #[rstest]
434    fn test_evm_private_key_debug_redacts() {
435        let key = EvmPrivateKey::new(TEST_PRIVATE_KEY).unwrap();
436        let debug = format!("{key:?}");
437        assert_eq!(debug, format!("EvmPrivateKey({REDACTED})"));
438        assert!(!debug.contains("1234"));
439    }
440
441    #[rstest]
442    fn test_credential_creation() {
443        let cred = Credential::new(
444            "test_api_key".into(),
445            test_secret_b64().into(),
446            "test_pass".into(),
447        )
448        .unwrap();
449        let api_secret = cred.api_secret();
450
451        assert_eq!(cred.api_key_str(), "test_api_key");
452        assert_eq!(cred.passphrase(), "test_pass");
453        assert_eq!(api_secret.expose_secret(), test_secret_b64());
454        assert_eq!(format!("{api_secret:?}"), REDACTED);
455    }
456
457    #[rstest]
458    fn test_credential_invalid_base64_secret() {
459        let result = Credential::new("key".into(), "not-valid-base64!!!".into(), "pass".into());
460        assert!(result.is_err());
461    }
462
463    #[rstest]
464    fn test_credential_sign_produces_base64() {
465        let cred = Credential::new(
466            "key".into(),
467            URL_SAFE.encode(b"test_secret").into(),
468            "pass".into(),
469        )
470        .unwrap();
471
472        let sig = cred.sign("1234567890", "GET", "/order", "");
473        assert!(URL_SAFE.decode(&sig).is_ok());
474    }
475
476    #[rstest]
477    fn test_credential_sign_deterministic() {
478        let cred = Credential::new(
479            "key".into(),
480            URL_SAFE.encode(b"deterministic_test").into(),
481            "pass".into(),
482        )
483        .unwrap();
484
485        let sig1 = cred.sign("1000", "POST", "/order", r#"{"price":"0.5"}"#);
486        let sig2 = cred.sign("1000", "POST", "/order", r#"{"price":"0.5"}"#);
487        assert_eq!(sig1, sig2);
488    }
489
490    #[rstest]
491    fn test_credential_sign_different_timestamps() {
492        let cred = Credential::new(
493            "key".into(),
494            URL_SAFE.encode(b"test_key").into(),
495            "pass".into(),
496        )
497        .unwrap();
498
499        let sig1 = cred.sign("1000", "GET", "/order", "");
500        let sig2 = cred.sign("1001", "GET", "/order", "");
501        assert_ne!(sig1, sig2);
502    }
503
504    #[rstest]
505    fn test_credential_sign_different_methods() {
506        let cred = Credential::new(
507            "key".into(),
508            URL_SAFE.encode(b"test_key").into(),
509            "pass".into(),
510        )
511        .unwrap();
512
513        let sig1 = cred.sign("1000", "GET", "/order", "");
514        let sig2 = cred.sign("1000", "POST", "/order", "");
515        assert_ne!(sig1, sig2);
516    }
517
518    #[rstest]
519    fn test_credential_sign_different_paths() {
520        let cred = Credential::new(
521            "key".into(),
522            URL_SAFE.encode(b"test_key").into(),
523            "pass".into(),
524        )
525        .unwrap();
526
527        let sig1 = cred.sign("1000", "GET", "/order", "");
528        let sig2 = cred.sign("1000", "GET", "/trades", "");
529        assert_ne!(sig1, sig2);
530    }
531
532    #[rstest]
533    fn test_credential_sign_different_bodies() {
534        let cred = Credential::new(
535            "key".into(),
536            URL_SAFE.encode(b"test_key").into(),
537            "pass".into(),
538        )
539        .unwrap();
540
541        let sig1 = cred.sign("1000", "POST", "/order", r#"{"a":1}"#);
542        let sig2 = cred.sign("1000", "POST", "/order", r#"{"a":2}"#);
543        assert_ne!(sig1, sig2);
544    }
545
546    #[rstest]
547    fn test_credential_sign_empty_body() {
548        let cred = Credential::new(
549            "key".into(),
550            URL_SAFE.encode(b"test_key").into(),
551            "pass".into(),
552        )
553        .unwrap();
554
555        let sig1 = cred.sign("1000", "GET", "/order", "");
556        let sig2 = cred.sign("1000", "GET", "/order", "{}");
557        assert_ne!(sig1, sig2);
558    }
559
560    // Test vectors from Polymarket SDK (rs-clob-client/src/auth.rs)
561    const SDK_SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
562    const SDK_PASSPHRASE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
563
564    #[rstest]
565    fn test_credential_sign_matches_sdk_l2_vector() {
566        let cred = Credential::new(
567            "00000000-0000-0000-0000-000000000000".into(),
568            SDK_SECRET.into(),
569            SDK_PASSPHRASE.into(),
570        )
571        .unwrap();
572
573        // SDK test: timestamp=1, GET, "/", empty body
574        let sig = cred.sign("1", "GET", "/", "");
575        assert_eq!(sig, "eHaylCwqRSOa2LFD77Nt_SaTpbsxzN8eTEI3LryhEj4=");
576    }
577
578    #[rstest]
579    fn test_credential_sign_matches_sdk_hmac_vector() {
580        let cred = Credential::new("key".into(), SDK_SECRET.into(), "pass".into()).unwrap();
581
582        // SDK test: raw message "1000000test-sign/orders{"hash":"0x123"}"
583        let sig = cred.sign("1000000", "test-sign", "/orders", r#"{"hash":"0x123"}"#);
584        assert_eq!(sig, "4gJVbox-R6XlDK4nlaicig0_ANVL1qdcahiL8CXfXLM=");
585    }
586
587    #[rstest]
588    fn test_credential_debug_redacts_secret() {
589        let cred = Credential::new(
590            "my_api_key_12345678".into(),
591            test_secret_b64().into(),
592            "my_passphrase".into(),
593        )
594        .unwrap();
595
596        let debug = format!("{cred:?}");
597        assert_eq!(debug.matches(REDACTED).count(), 3);
598        assert!(!debug.contains("my_api_key_12345678"));
599        assert!(!debug.contains("test_secret"));
600        assert!(!debug.contains("my_passphrase"));
601    }
602
603    #[rstest]
604    fn test_relayer_api_key_normalizes_address_and_redacts_key() {
605        let key = RelayerApiKey::new(
606            "relayer-secret".into(),
607            "0xF39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
608        )
609        .unwrap();
610        assert_eq!(key.key(), "relayer-secret");
611        assert_eq!(key.address(), "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266");
612        let debug = format!("{key:?}");
613        assert!(debug.contains(REDACTED));
614        assert!(!debug.contains("relayer-secret"));
615    }
616
617    #[rstest]
618    fn test_relayer_api_key_retains_secret_owner_and_allocation() {
619        let mut value = String::with_capacity(4096);
620        value.push_str("relayer-secret");
621        let allocation = value.as_ptr();
622        let key =
623            RelayerApiKey::new(value.into(), "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266").unwrap();
624        let secret: &SecretString = &key.key;
625
626        assert_eq!(secret.expose_secret().as_ptr(), allocation);
627
628        let cloned = key.clone();
629        drop(key);
630
631        assert_eq!(cloned.key(), "relayer-secret");
632        assert_eq!(
633            cloned.address(),
634            "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"
635        );
636        assert_eq!(
637            format!("{cloned:?}"),
638            "RelayerApiKey { key: \"<redacted>\", address: \"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266\" }",
639        );
640    }
641
642    #[rstest]
643    fn test_relayer_api_key_rejects_empty_key_and_invalid_address() {
644        assert!(
645            RelayerApiKey::new("".into(), "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266").is_err()
646        );
647        assert!(RelayerApiKey::new("key".into(), "not-an-address").is_err());
648    }
649}