mod credentials;
mod signer;
mod wallet;
use std::path::Path;
use alloy::primitives::Address;
pub use credentials::Credentials;
use serde::{Deserialize, Serialize};
pub use signer::Signer;
pub use wallet::Wallet;
use crate::{
core::eip712::{sign_clob_auth, sign_order},
error::ClobError,
types::{Order, SignedOrder},
};
pub mod env {
pub const PRIVATE_KEY: &str = "POLYMARKET_PRIVATE_KEY";
pub const API_KEY: &str = "POLYMARKET_API_KEY";
pub const API_SECRET: &str = "POLYMARKET_API_SECRET";
pub const API_PASSPHRASE: &str = "POLYMARKET_API_PASSPHRASE";
}
#[cfg(feature = "keychain")]
pub const KEYCHAIN_SERVICE: &str = "polyoxide-clob";
#[derive(Clone, Serialize, Deserialize)]
pub struct AccountConfig {
pub private_key: String,
#[serde(flatten)]
pub credentials: Credentials,
}
impl std::fmt::Debug for AccountConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AccountConfig")
.field("private_key", &"[REDACTED]")
.field("credentials", &self.credentials)
.finish()
}
}
#[derive(Clone, Debug)]
pub struct Account {
wallet: Wallet,
credentials: Credentials,
signer: Signer,
}
impl Account {
pub fn new(
private_key: impl Into<String>,
credentials: Credentials,
) -> Result<Self, ClobError> {
let wallet = Wallet::from_private_key(&private_key.into())?;
let signer = Signer::new(&credentials.secret);
Ok(Self {
wallet,
credentials,
signer,
})
}
pub fn from_env() -> Result<Self, ClobError> {
let private_key = std::env::var(env::PRIVATE_KEY).map_err(|_| {
ClobError::validation(format!(
"Missing environment variable: {}",
env::PRIVATE_KEY
))
})?;
let credentials = Credentials {
key: std::env::var(env::API_KEY).map_err(|_| {
ClobError::validation(format!("Missing environment variable: {}", env::API_KEY))
})?,
secret: std::env::var(env::API_SECRET).map_err(|_| {
ClobError::validation(format!("Missing environment variable: {}", env::API_SECRET))
})?,
passphrase: std::env::var(env::API_PASSPHRASE).map_err(|_| {
ClobError::validation(format!(
"Missing environment variable: {}",
env::API_PASSPHRASE
))
})?,
};
Self::new(private_key, credentials)
}
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ClobError> {
let path = path.as_ref();
let content = std::fs::read_to_string(path).map_err(|e| {
ClobError::validation(format!(
"Failed to read config file {}: {}",
path.display(),
e
))
})?;
Self::from_json(&content)
}
pub fn from_json(json: &str) -> Result<Self, ClobError> {
let config: AccountConfig = serde_json::from_str(json)
.map_err(|e| ClobError::validation(format!("Failed to parse JSON config: {}", e)))?;
Self::new(config.private_key, config.credentials)
}
#[cfg(feature = "keychain")]
pub fn from_keychain() -> Result<Self, ClobError> {
use polyoxide_core::keychain;
let private_key = keychain::get(KEYCHAIN_SERVICE, "private_key")
.map_err(|e| ClobError::validation(format!("Keychain error for private_key: {e}")))?;
let credentials = Credentials {
key: keychain::get(KEYCHAIN_SERVICE, "api_key")
.map_err(|e| ClobError::validation(format!("Keychain error for api_key: {e}")))?,
secret: keychain::get(KEYCHAIN_SERVICE, "api_secret").map_err(|e| {
ClobError::validation(format!("Keychain error for api_secret: {e}"))
})?,
passphrase: keychain::get(KEYCHAIN_SERVICE, "api_passphrase").map_err(|e| {
ClobError::validation(format!("Keychain error for api_passphrase: {e}"))
})?,
};
Self::new(private_key, credentials)
}
#[cfg(feature = "keychain")]
pub fn save_to_keychain(&self) -> Result<(), ClobError> {
use polyoxide_core::keychain;
keychain::set(KEYCHAIN_SERVICE, "api_key", &self.credentials.key)
.map_err(|e| ClobError::validation(format!("Keychain error: {e}")))?;
keychain::set(KEYCHAIN_SERVICE, "api_secret", &self.credentials.secret)
.map_err(|e| ClobError::validation(format!("Keychain error: {e}")))?;
keychain::set(
KEYCHAIN_SERVICE,
"api_passphrase",
&self.credentials.passphrase,
)
.map_err(|e| ClobError::validation(format!("Keychain error: {e}")))?;
Ok(())
}
#[cfg(feature = "keychain")]
pub fn delete_from_keychain() -> Result<(), ClobError> {
use polyoxide_core::keychain;
for key in ["private_key", "api_key", "api_secret", "api_passphrase"] {
keychain::delete(KEYCHAIN_SERVICE, key)
.map_err(|e| ClobError::validation(format!("Keychain error: {e}")))?;
}
Ok(())
}
pub fn address(&self) -> Address {
self.wallet.address()
}
pub fn wallet(&self) -> &Wallet {
&self.wallet
}
pub fn credentials(&self) -> &Credentials {
&self.credentials
}
pub fn signer(&self) -> &Signer {
&self.signer
}
pub async fn sign_order(&self, order: &Order, chain_id: u64) -> Result<SignedOrder, ClobError> {
let signature = sign_order(order, self.wallet.signer(), chain_id).await?;
Ok(SignedOrder {
order: order.clone(),
signature,
})
}
pub async fn sign_clob_auth(
&self,
chain_id: u64,
timestamp: u64,
nonce: u32,
) -> Result<String, ClobError> {
sign_clob_auth(self.wallet.signer(), chain_id, timestamp, nonce).await
}
pub fn sign_l2_request(
&self,
timestamp: u64,
method: &str,
path: &str,
body: Option<&str>,
) -> Result<String, ClobError> {
let message = Signer::create_message(timestamp, method, path, body);
self.signer.sign(&message)
}
}
#[cfg(feature = "keychain")]
pub fn save_private_key_to_keychain(private_key: &str) -> Result<(), ClobError> {
polyoxide_core::keychain::set(KEYCHAIN_SERVICE, "private_key", private_key)
.map_err(|e| ClobError::validation(format!("Keychain error: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_json() {
let json = r#"{
"private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
"key": "test_key",
"secret": "c2VjcmV0",
"passphrase": "test_pass"
}"#;
let account = Account::from_json(json).unwrap();
assert_eq!(account.credentials().key, "test_key");
assert_eq!(account.credentials().passphrase, "test_pass");
}
#[test]
fn test_account_config_debug_redacts_private_key() {
let config = AccountConfig {
private_key: "0xdeadbeef_super_secret_key".to_string(),
credentials: Credentials {
key: "api_key".to_string(),
secret: "api_secret".to_string(),
passphrase: "pass".to_string(),
},
};
let debug_output = format!("{:?}", config);
assert!(
debug_output.contains("[REDACTED]"),
"Debug should contain [REDACTED], got: {debug_output}"
);
assert!(
!debug_output.contains("deadbeef"),
"Debug should not contain the private key, got: {debug_output}"
);
}
#[cfg(feature = "keychain")]
mod keychain_tests {
use super::*;
#[test]
#[ignore] fn keychain_roundtrip() {
let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
let credentials = Credentials {
key: "test_keychain_key".to_string(),
secret: "c2VjcmV0".to_string(),
passphrase: "test_keychain_pass".to_string(),
};
save_private_key_to_keychain(private_key).unwrap();
let account = Account::new(private_key, credentials).unwrap();
account.save_to_keychain().unwrap();
let loaded = Account::from_keychain().unwrap();
assert_eq!(loaded.credentials().key, "test_keychain_key");
assert_eq!(loaded.credentials().secret, "c2VjcmV0");
assert_eq!(loaded.credentials().passphrase, "test_keychain_pass");
assert_eq!(loaded.address(), account.address());
Account::delete_from_keychain().unwrap();
}
#[test]
#[ignore] fn keychain_delete_removes_all_entries() {
let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
let credentials = Credentials {
key: "del_test_key".to_string(),
secret: "c2VjcmV0".to_string(),
passphrase: "del_test_pass".to_string(),
};
save_private_key_to_keychain(private_key).unwrap();
Account::new(private_key, credentials)
.unwrap()
.save_to_keychain()
.unwrap();
Account::delete_from_keychain().unwrap();
let err = Account::from_keychain().unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Keychain entry not found"),
"Expected NotFound after delete, got: {msg}"
);
}
}
#[test]
fn test_sign_l2_request() {
let json = r#"{
"private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
"key": "test_key",
"secret": "c2VjcmV0",
"passphrase": "test_pass"
}"#;
let account = Account::from_json(json).unwrap();
let signature = account
.sign_l2_request(1234567890, "GET", "/api/test", None)
.unwrap();
assert!(!signature.contains('+'));
assert!(!signature.contains('/'));
}
}