1use crate::error::{PrivyError, PrivyResult};
2use crate::models::{SignTransactionResponse, Wallet, WalletsResponse};
3use base64::Engine;
4use p256::ecdsa::{signature::Signer, Signature, SigningKey};
5use p256::pkcs8::DecodePrivateKey;
6use reqwest::Client;
7use secrecy::{ExposeSecret, SecretString};
8use std::time::Duration;
9
10const DEFAULT_API_URL: &str = "https://api.privy.io";
11const DEFAULT_TIMEOUT_SECS: u64 = 30;
12
13pub struct PrivyConfig {
18 pub api_url: String,
20 pub app_id: String,
22 pub app_secret: SecretString,
24 pub authorization_key: SecretString,
26}
27
28impl PrivyConfig {
29 pub fn new(app_id: String, app_secret: SecretString, authorization_key: SecretString) -> Self {
30 Self {
31 api_url: DEFAULT_API_URL.to_string(),
32 app_id,
33 app_secret,
34 authorization_key,
35 }
36 }
37
38 pub fn with_api_url(mut self, url: String) -> Self {
39 self.api_url = url;
40 self
41 }
42}
43
44#[derive(Clone)]
51pub struct PrivyClient {
52 http_client: Client,
53 api_url: String,
54 app_id: String,
55 app_secret: SecretString,
56 authorization_signing_key: SigningKey,
57}
58
59impl PrivyClient {
60 pub fn new(config: PrivyConfig) -> PrivyResult<Self> {
61 let authorization_signing_key =
62 parse_authorization_key(config.authorization_key.expose_secret())?;
63 let http_client = Client::builder()
64 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
65 .build()
66 .map_err(|e| PrivyError::Config(format!("Failed to build HTTP client: {e}")))?;
67
68 Ok(Self {
69 http_client,
70 api_url: config.api_url.trim_end_matches('/').to_string(),
71 app_id: config.app_id,
72 app_secret: config.app_secret,
73 authorization_signing_key,
74 })
75 }
76
77 pub fn app_id(&self) -> &str {
79 &self.app_id
80 }
81
82 pub async fn get_user_solana_wallet(&self, user_did: &str) -> PrivyResult<Option<Wallet>> {
84 let response = self
85 .http_client
86 .get(format!("{}/v1/wallets", self.api_url))
87 .query(&[("user_id", user_did), ("chain_type", "solana")])
88 .basic_auth(&self.app_id, Some(self.app_secret.expose_secret()))
89 .header("privy-app-id", &self.app_id)
90 .send()
91 .await?;
92
93 let status = response.status();
94 if !status.is_success() {
95 let body = response
96 .text()
97 .await
98 .unwrap_or_else(|_| "Unknown error".to_string());
99 return Err(PrivyError::Api {
100 status: status.as_u16(),
101 body,
102 });
103 }
104
105 let wallets: WalletsResponse = response.json().await?;
106 Ok(wallets.data.into_iter().find(|w| w.chain_type == "solana"))
107 }
108
109 pub async fn sign_transaction(
114 &self,
115 wallet_id: &str,
116 transaction_base64: &str,
117 ) -> PrivyResult<String> {
118 let url = format!("{}/v1/wallets/{}/rpc", self.api_url, wallet_id);
119
120 let body = serde_json::json!({
121 "method": "signTransaction",
122 "params": {
123 "transaction": transaction_base64,
124 "encoding": "base64"
125 }
126 });
127
128 let authorization_signature = self.compute_authorization_signature("POST", &url, &body)?;
129
130 let response = self
131 .http_client
132 .post(&url)
133 .basic_auth(&self.app_id, Some(self.app_secret.expose_secret()))
134 .header("privy-app-id", &self.app_id)
135 .header("privy-authorization-signature", &authorization_signature)
136 .json(&body)
137 .send()
138 .await?;
139
140 let status = response.status();
141 if !status.is_success() {
142 let body = response
143 .text()
144 .await
145 .unwrap_or_else(|_| "Unknown error".to_string());
146 return Err(PrivyError::Api {
147 status: status.as_u16(),
148 body,
149 });
150 }
151
152 let resp: SignTransactionResponse = response.json().await?;
153 Ok(resp.data.signed_transaction)
154 }
155
156 pub fn compute_authorization_signature(
162 &self,
163 http_method: &str,
164 url: &str,
165 body: &serde_json::Value,
166 ) -> PrivyResult<String> {
167 let signing_payload = serde_json::json!({
168 "version": 1,
169 "method": http_method,
170 "url": url,
171 "body": body,
172 "headers": {
173 "privy-app-id": self.app_id
174 }
175 });
176
177 let payload_string = serde_json::to_string(&signing_payload)?;
178 let signature: Signature = self
179 .authorization_signing_key
180 .sign(payload_string.as_bytes());
181
182 Ok(base64::engine::general_purpose::STANDARD.encode(signature.to_der()))
183 }
184
185 pub async fn get_with_basic_auth(&self, url: &str) -> PrivyResult<reqwest::Response> {
188 let response = self
189 .http_client
190 .get(url)
191 .basic_auth(&self.app_id, Some(self.app_secret.expose_secret()))
192 .header("privy-app-id", &self.app_id)
193 .send()
194 .await?;
195 Ok(response)
196 }
197
198 pub async fn request_with_signature(
201 &self,
202 method: reqwest::Method,
203 url: &str,
204 body: &serde_json::Value,
205 ) -> PrivyResult<reqwest::Response> {
206 let authorization_signature =
207 self.compute_authorization_signature(method.as_str(), url, body)?;
208
209 let response = self
210 .http_client
211 .request(method, url)
212 .basic_auth(&self.app_id, Some(self.app_secret.expose_secret()))
213 .header("privy-app-id", &self.app_id)
214 .header("privy-authorization-signature", &authorization_signature)
215 .json(body)
216 .send()
217 .await?;
218 Ok(response)
219 }
220}
221
222fn parse_authorization_key(authorization_key: &str) -> PrivyResult<SigningKey> {
224 let key_b64 = authorization_key
225 .strip_prefix("wallet-auth:")
226 .ok_or_else(|| {
227 PrivyError::Config("authorization_key must start with 'wallet-auth:'".to_string())
228 })?;
229
230 let der_bytes = base64::engine::general_purpose::STANDARD
231 .decode(key_b64)
232 .map_err(|e| PrivyError::Config(format!("Invalid authorization key base64: {e}")))?;
233
234 SigningKey::from_pkcs8_der(&der_bytes)
235 .map_err(|e| PrivyError::Config(format!("Invalid P-256 PKCS#8 private key: {e}")))
236}
237
238#[cfg(test)]
239#[allow(
240 clippy::allow_attributes,
241 clippy::allow_attributes_without_reason,
242 clippy::unwrap_used,
243 clippy::expect_used,
244 clippy::panic,
245 clippy::arithmetic_side_effects
246)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn parse_authorization_key_missing_prefix() {
252 let result = parse_authorization_key("invalid-key");
253 assert!(result.is_err());
254 let err = result.unwrap_err();
255 assert!(
256 err.to_string().contains("wallet-auth:"),
257 "error should mention expected prefix: {}",
258 err
259 );
260 }
261
262 #[test]
263 fn parse_authorization_key_invalid_base64() {
264 let result = parse_authorization_key("wallet-auth:not-valid-base64!!!");
265 assert!(result.is_err());
266 let err = result.unwrap_err();
267 assert!(
268 err.to_string().contains("base64"),
269 "error should mention base64: {}",
270 err
271 );
272 }
273
274 #[test]
275 fn parse_authorization_key_invalid_der() {
276 let result = parse_authorization_key("wallet-auth:AAAA");
278 assert!(result.is_err());
279 let err = result.unwrap_err();
280 assert!(
281 err.to_string().contains("P-256") || err.to_string().contains("PKCS"),
282 "error should mention key format: {}",
283 err
284 );
285 }
286
287 #[test]
288 fn compute_signature_deterministic() {
289 use p256::pkcs8::EncodePrivateKey;
290
291 let signing_key = SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
293 let der = signing_key
294 .to_pkcs8_der()
295 .expect("should serialize to PKCS8");
296 let key_b64 = base64::engine::general_purpose::STANDARD.encode(der.as_bytes());
297 let auth_key = format!("wallet-auth:{key_b64}");
298
299 let config = PrivyConfig::new(
300 "test-app-id".to_string(),
301 SecretString::from("test-secret".to_string()),
302 SecretString::from(auth_key),
303 );
304 let client = PrivyClient::new(config).expect("should create client");
305
306 let body = serde_json::json!({"test": "data"});
307 let sig1 = client
308 .compute_authorization_signature("POST", "https://api.privy.io/test", &body)
309 .expect("should compute signature");
310 let sig2 = client
311 .compute_authorization_signature("POST", "https://api.privy.io/test", &body)
312 .expect("should compute signature");
313
314 assert!(!sig1.is_empty());
317 assert!(!sig2.is_empty());
318 assert!(base64::engine::general_purpose::STANDARD
320 .decode(&sig1)
321 .is_ok());
322 assert!(base64::engine::general_purpose::STANDARD
323 .decode(&sig2)
324 .is_ok());
325 }
326
327 #[test]
328 fn client_creation_with_valid_key() {
329 use p256::pkcs8::EncodePrivateKey;
330
331 let signing_key = SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
332 let der = signing_key.to_pkcs8_der().expect("should encode");
333 let key_b64 = base64::engine::general_purpose::STANDARD.encode(der.as_bytes());
334
335 let config = PrivyConfig::new(
336 "app-id".to_string(),
337 SecretString::from("secret".to_string()),
338 SecretString::from(format!("wallet-auth:{key_b64}")),
339 );
340 let client = PrivyClient::new(config);
341 assert!(client.is_ok());
342 }
343}