use crate::error::{PrivyError, PrivyResult};
use crate::models::{SignTransactionResponse, Wallet, WalletsResponse};
use base64::Engine;
use p256::ecdsa::{signature::Signer, Signature, SigningKey};
use p256::pkcs8::DecodePrivateKey;
use reqwest::Client;
use secrecy::{ExposeSecret, SecretString};
use std::time::Duration;
const DEFAULT_API_URL: &str = "https://api.privy.io";
const DEFAULT_TIMEOUT_SECS: u64 = 30;
pub struct PrivyConfig {
pub api_url: String,
pub app_id: String,
pub app_secret: SecretString,
pub authorization_key: SecretString,
}
impl PrivyConfig {
pub fn new(app_id: String, app_secret: SecretString, authorization_key: SecretString) -> Self {
Self {
api_url: DEFAULT_API_URL.to_string(),
app_id,
app_secret,
authorization_key,
}
}
pub fn with_api_url(mut self, url: String) -> Self {
self.api_url = url;
self
}
}
#[derive(Clone)]
pub struct PrivyClient {
http_client: Client,
api_url: String,
app_id: String,
app_secret: SecretString,
authorization_signing_key: SigningKey,
}
impl PrivyClient {
pub fn new(config: PrivyConfig) -> PrivyResult<Self> {
let authorization_signing_key =
parse_authorization_key(config.authorization_key.expose_secret())?;
let http_client = Client::builder()
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
.build()
.map_err(|e| PrivyError::Config(format!("Failed to build HTTP client: {e}")))?;
Ok(Self {
http_client,
api_url: config.api_url.trim_end_matches('/').to_string(),
app_id: config.app_id,
app_secret: config.app_secret,
authorization_signing_key,
})
}
pub fn app_id(&self) -> &str {
&self.app_id
}
pub async fn get_user_solana_wallet(&self, user_did: &str) -> PrivyResult<Option<Wallet>> {
let response = self
.http_client
.get(format!("{}/v1/wallets", self.api_url))
.query(&[("user_id", user_did), ("chain_type", "solana")])
.basic_auth(&self.app_id, Some(self.app_secret.expose_secret()))
.header("privy-app-id", &self.app_id)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let body = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(PrivyError::Api {
status: status.as_u16(),
body,
});
}
let wallets: WalletsResponse = response.json().await?;
Ok(wallets.data.into_iter().find(|w| w.chain_type == "solana"))
}
pub async fn sign_transaction(
&self,
wallet_id: &str,
transaction_base64: &str,
) -> PrivyResult<String> {
let url = format!("{}/v1/wallets/{}/rpc", self.api_url, wallet_id);
let body = serde_json::json!({
"method": "signTransaction",
"params": {
"transaction": transaction_base64,
"encoding": "base64"
}
});
let authorization_signature = self.compute_authorization_signature("POST", &url, &body)?;
let response = self
.http_client
.post(&url)
.basic_auth(&self.app_id, Some(self.app_secret.expose_secret()))
.header("privy-app-id", &self.app_id)
.header("privy-authorization-signature", &authorization_signature)
.json(&body)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let body = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(PrivyError::Api {
status: status.as_u16(),
body,
});
}
let resp: SignTransactionResponse = response.json().await?;
Ok(resp.data.signed_transaction)
}
pub fn compute_authorization_signature(
&self,
http_method: &str,
url: &str,
body: &serde_json::Value,
) -> PrivyResult<String> {
let signing_payload = serde_json::json!({
"version": 1,
"method": http_method,
"url": url,
"body": body,
"headers": {
"privy-app-id": self.app_id
}
});
let payload_string = serde_json::to_string(&signing_payload)?;
let signature: Signature = self
.authorization_signing_key
.sign(payload_string.as_bytes());
Ok(base64::engine::general_purpose::STANDARD.encode(signature.to_der()))
}
pub async fn get_with_basic_auth(&self, url: &str) -> PrivyResult<reqwest::Response> {
let response = self
.http_client
.get(url)
.basic_auth(&self.app_id, Some(self.app_secret.expose_secret()))
.header("privy-app-id", &self.app_id)
.send()
.await?;
Ok(response)
}
pub async fn request_with_signature(
&self,
method: reqwest::Method,
url: &str,
body: &serde_json::Value,
) -> PrivyResult<reqwest::Response> {
let authorization_signature =
self.compute_authorization_signature(method.as_str(), url, body)?;
let response = self
.http_client
.request(method, url)
.basic_auth(&self.app_id, Some(self.app_secret.expose_secret()))
.header("privy-app-id", &self.app_id)
.header("privy-authorization-signature", &authorization_signature)
.json(body)
.send()
.await?;
Ok(response)
}
}
fn parse_authorization_key(authorization_key: &str) -> PrivyResult<SigningKey> {
let key_b64 = authorization_key
.strip_prefix("wallet-auth:")
.ok_or_else(|| {
PrivyError::Config("authorization_key must start with 'wallet-auth:'".to_string())
})?;
let der_bytes = base64::engine::general_purpose::STANDARD
.decode(key_b64)
.map_err(|e| PrivyError::Config(format!("Invalid authorization key base64: {e}")))?;
SigningKey::from_pkcs8_der(&der_bytes)
.map_err(|e| PrivyError::Config(format!("Invalid P-256 PKCS#8 private key: {e}")))
}
#[cfg(test)]
#[allow(
clippy::allow_attributes,
clippy::allow_attributes_without_reason,
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::arithmetic_side_effects
)]
mod tests {
use super::*;
#[test]
fn parse_authorization_key_missing_prefix() {
let result = parse_authorization_key("invalid-key");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("wallet-auth:"),
"error should mention expected prefix: {}",
err
);
}
#[test]
fn parse_authorization_key_invalid_base64() {
let result = parse_authorization_key("wallet-auth:not-valid-base64!!!");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("base64"),
"error should mention base64: {}",
err
);
}
#[test]
fn parse_authorization_key_invalid_der() {
let result = parse_authorization_key("wallet-auth:AAAA");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("P-256") || err.to_string().contains("PKCS"),
"error should mention key format: {}",
err
);
}
#[test]
fn compute_signature_deterministic() {
use p256::pkcs8::EncodePrivateKey;
let signing_key = SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
let der = signing_key
.to_pkcs8_der()
.expect("should serialize to PKCS8");
let key_b64 = base64::engine::general_purpose::STANDARD.encode(der.as_bytes());
let auth_key = format!("wallet-auth:{key_b64}");
let config = PrivyConfig::new(
"test-app-id".to_string(),
SecretString::from("test-secret".to_string()),
SecretString::from(auth_key),
);
let client = PrivyClient::new(config).expect("should create client");
let body = serde_json::json!({"test": "data"});
let sig1 = client
.compute_authorization_signature("POST", "https://api.privy.io/test", &body)
.expect("should compute signature");
let sig2 = client
.compute_authorization_signature("POST", "https://api.privy.io/test", &body)
.expect("should compute signature");
assert!(!sig1.is_empty());
assert!(!sig2.is_empty());
assert!(base64::engine::general_purpose::STANDARD
.decode(&sig1)
.is_ok());
assert!(base64::engine::general_purpose::STANDARD
.decode(&sig2)
.is_ok());
}
#[test]
fn client_creation_with_valid_key() {
use p256::pkcs8::EncodePrivateKey;
let signing_key = SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
let der = signing_key.to_pkcs8_der().expect("should encode");
let key_b64 = base64::engine::general_purpose::STANDARD.encode(der.as_bytes());
let config = PrivyConfig::new(
"app-id".to_string(),
SecretString::from("secret".to_string()),
SecretString::from(format!("wallet-auth:{key_b64}")),
);
let client = PrivyClient::new(config);
assert!(client.is_ok());
}
}