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
12pub(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 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 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}