Skip to main content

pyra_privy/
client.rs

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
13/// Configuration for creating a [`PrivyClient`].
14///
15/// Sensitive fields (`app_secret`, `authorization_key`) are wrapped in
16/// `SecretString` to prevent accidental logging via `Debug`.
17pub struct PrivyConfig {
18    /// Privy API base URL (defaults to `https://api.privy.io`).
19    pub api_url: String,
20    /// Privy app ID.
21    pub app_id: String,
22    /// Privy app secret (used for HTTP basic auth).
23    pub app_secret: SecretString,
24    /// Authorization key in `"wallet-auth:<base64-pkcs8-der>"` format.
25    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/// Privy API client for wallet operations and transaction signing.
45///
46/// Shared core used by both api-v2 and settlement-service:
47/// - P-256 ECDSA authorization signatures
48/// - Wallet lookup (GET /v1/wallets)
49/// - Transaction signing (POST /v1/wallets/{id}/rpc)
50#[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    /// Return the app ID.
78    pub fn app_id(&self) -> &str {
79        &self.app_id
80    }
81
82    /// Get the user's first Solana wallet from Privy.
83    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    /// Sign a transaction using a Privy-managed wallet.
110    ///
111    /// Takes the Privy wallet ID and a base64-encoded serialized transaction.
112    /// Returns the signed transaction as base64.
113    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    /// Compute the `privy-authorization-signature` header value.
157    ///
158    /// P-256 ECDSA signature over canonical JSON payload, encoded as base64 DER.
159    /// serde_json serializes `json!({})` maps using BTreeMap (alphabetical key order),
160    /// which satisfies RFC 8785 for our payload.
161    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    /// Make a basic-auth-only request (no authorization signature).
186    /// Used for GET endpoints like /v1/wallets and /v1/policies/{id}.
187    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    /// Make a request with authorization signature.
199    /// Used for POST/PATCH endpoints that modify wallet state.
200    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
222/// Parse the authorization key from config format `"wallet-auth:<base64-pkcs8-der>"`.
223fn 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        // Valid base64 but not a valid PKCS#8 key
277        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        // Generate a test key
292        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        // ECDSA with P-256 uses random nonce, so signatures differ each time.
315        // But both should be valid base64 and non-empty.
316        assert!(!sig1.is_empty());
317        assert!(!sig2.is_empty());
318        // Both should be valid base64
319        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}