Skip to main content

polymarket_us/
auth.rs

1use crate::error::PolymarketUsError;
2use base64::Engine as _;
3use ed25519_dalek::{Signer, SigningKey};
4
5pub const ENV_KEY_ID: &str = "POLYMARKET_US_KEY_ID";
6pub const ENV_SECRET_KEY: &str = "POLYMARKET_US_SECRET_KEY";
7
8pub const HEADER_ACCESS_KEY: &str = "X-PM-Access-Key";
9pub const HEADER_TIMESTAMP: &str = "X-PM-Timestamp";
10pub const HEADER_SIGNATURE: &str = "X-PM-Signature";
11
12/// Milliseconds since the Unix epoch.
13///
14/// Clocks set before 1970 clamp to `0` rather than panicking; the server will
15/// reject the resulting signature as stale, which is the correct outcome.
16pub(crate) fn unix_timestamp_millis() -> i64 {
17    std::time::SystemTime::now()
18        .duration_since(std::time::UNIX_EPOCH)
19        .map(|d| d.as_millis() as i64)
20        .unwrap_or(0)
21}
22
23#[derive(Clone)]
24pub struct UsAuth {
25    key_id: String,
26    signing_key: SigningKey,
27}
28
29impl UsAuth {
30    /// Load credentials from the [`ENV_KEY_ID`] and [`ENV_SECRET_KEY`]
31    /// environment variables.
32    pub fn from_env() -> Result<Self, PolymarketUsError> {
33        let key_id = std::env::var(ENV_KEY_ID)
34            .map_err(|_| PolymarketUsError::InvalidCredentials(format!("{ENV_KEY_ID} not set")))?;
35        let secret_b64 = std::env::var(ENV_SECRET_KEY).map_err(|_| {
36            PolymarketUsError::InvalidCredentials(format!("{ENV_SECRET_KEY} not set"))
37        })?;
38        Self::from_parts(key_id, &secret_b64)
39    }
40
41    /// Build credentials from a key ID and a Base64-encoded Ed25519 secret.
42    ///
43    /// The secret must decode to either 64 bytes (keypair — the first 32 are
44    /// used as the signing seed) or 32 bytes (a raw seed).
45    pub fn from_parts(key_id: String, secret_b64: &str) -> Result<Self, PolymarketUsError> {
46        let secret = base64::engine::general_purpose::STANDARD
47            .decode(secret_b64.trim())
48            .map_err(|err| {
49                PolymarketUsError::InvalidCredentials(format!(
50                    "{ENV_SECRET_KEY} is not valid Base64: {err}"
51                ))
52            })?;
53
54        let seed: [u8; 32] = match secret.len() {
55            64 => secret[..32].try_into().expect("first 32 of 64 bytes"),
56            32 => secret.as_slice().try_into().expect("len checked == 32"),
57            n => {
58                return Err(PolymarketUsError::InvalidCredentials(format!(
59                    "{ENV_SECRET_KEY} must decode to 64 bytes (keypair) or 32 bytes (seed), got {n}"
60                )))
61            }
62        };
63
64        Ok(Self {
65            key_id,
66            signing_key: SigningKey::from_bytes(&seed),
67        })
68    }
69
70    pub fn key_id(&self) -> &str {
71        &self.key_id
72    }
73
74    fn signing_payload(timestamp_ms: i64, method: &str, path: &str) -> String {
75        format!("{}{}{}", timestamp_ms, method.to_uppercase(), path)
76    }
77
78    pub fn sign(&self, method: &str, path: &str) -> (i64, String) {
79        let ts = unix_timestamp_millis();
80        let payload = Self::signing_payload(ts, method, path);
81        let sig_bytes = self.signing_key.sign(payload.as_bytes()).to_bytes();
82        let signature = base64::engine::general_purpose::STANDARD.encode(sig_bytes);
83        (ts, signature)
84    }
85
86    pub fn signed_headers(&self, method: &str, path: &str) -> [(&'static str, String); 3] {
87        let (ts, sig) = self.sign(method, path);
88        [
89            (HEADER_ACCESS_KEY, self.key_id.clone()),
90            (HEADER_TIMESTAMP, ts.to_string()),
91            (HEADER_SIGNATURE, sig),
92        ]
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use ed25519_dalek::{Verifier, VerifyingKey};
100
101    const SAMPLE_SECRET: &str =
102        "lxcsopNhvp+FyZMtVPnHPeHAGihFMPEZcUg6TrJX6kCfwSEXu8v8vmyi3wJbMFUs3a9Fe7mkyRIwfZZkd/5kPg==";
103
104    #[test]
105    fn loads_64_byte_keypair_and_signs_verifiably() {
106        let auth = UsAuth::from_parts("483074f3-key".into(), SAMPLE_SECRET).unwrap();
107
108        let (ts, sig_b64) = auth.sign("GET", "/v1/account/balance");
109        let sig_bytes = base64::engine::general_purpose::STANDARD
110            .decode(sig_b64)
111            .unwrap();
112        let sig = ed25519_dalek::Signature::from_slice(&sig_bytes).unwrap();
113
114        let raw = base64::engine::general_purpose::STANDARD
115            .decode(SAMPLE_SECRET)
116            .unwrap();
117        let pub_bytes: [u8; 32] = raw[32..64].try_into().unwrap();
118        let vk = VerifyingKey::from_bytes(&pub_bytes).unwrap();
119        let payload = format!("{}GET/v1/account/balance", ts);
120        assert!(vk.verify(payload.as_bytes(), &sig).is_ok());
121    }
122}