Skip to main content

kora_privy/
mod.rs

1use base64::{engine::general_purpose::STANDARD, Engine};
2use solana_sdk::{pubkey::Pubkey, signature::Signature};
3use std::str::FromStr;
4
5mod types;
6mod utils;
7
8pub use types::*;
9pub use utils::*;
10
11impl PrivyConfig {
12    /// Load configuration from environment variables
13    pub fn from_env() -> Self {
14        Self {
15            app_id: std::env::var("PRIVY_APP_ID").ok(),
16            app_secret: std::env::var("PRIVY_APP_SECRET").ok(),
17            wallet_id: std::env::var("PRIVY_WALLET_ID").ok(),
18        }
19    }
20
21    /// Merge CLI arguments with existing config (CLI takes precedence)
22    pub fn merge_with_cli(
23        mut self,
24        app_id: Option<String>,
25        app_secret: Option<String>,
26        wallet_id: Option<String>,
27    ) -> Self {
28        if app_id.is_some() {
29            self.app_id = app_id;
30        }
31        if app_secret.is_some() {
32            self.app_secret = app_secret;
33        }
34        if wallet_id.is_some() {
35            self.wallet_id = wallet_id;
36        }
37        self
38    }
39
40    /// Build a PrivySigner from the config
41    pub fn build(self) -> Result<PrivySigner, PrivyError> {
42        Ok(PrivySigner {
43            app_id: self.app_id.ok_or(PrivyError::MissingConfig("app_id"))?,
44            app_secret: self.app_secret.ok_or(PrivyError::MissingConfig("app_secret"))?,
45            wallet_id: self.wallet_id.ok_or(PrivyError::MissingConfig("wallet_id"))?,
46            api_base_url: "https://api.privy.io/v1".to_string(),
47            client: reqwest::Client::new(),
48            public_key: Pubkey::default(), // Will be populated by init()
49        })
50    }
51}
52
53impl PrivySigner {
54    /// Create a new PrivySigner
55    pub fn new(app_id: String, app_secret: String, wallet_id: String) -> Self {
56        Self {
57            app_id,
58            app_secret,
59            wallet_id,
60            api_base_url: "https://api.privy.io/v1".to_string(),
61            client: reqwest::Client::new(),
62            public_key: Pubkey::default(),
63        }
64    }
65
66    /// Initialize the signer by fetching the public key
67    pub async fn init(&mut self) -> Result<(), PrivyError> {
68        let pubkey = self.get_public_key().await?;
69        self.public_key = pubkey;
70        Ok(())
71    }
72
73    /// Get the cached public key
74    pub fn solana_pubkey(&self) -> Pubkey {
75        self.public_key
76    }
77
78    /// Get the Basic Auth header value
79    fn get_auth_header(&self) -> String {
80        let credentials = format!("{}:{}", self.app_id, self.app_secret);
81        format!("Basic {}", STANDARD.encode(credentials))
82    }
83
84    /// Get the public key for this wallet
85    pub async fn get_public_key(&self) -> Result<Pubkey, PrivyError> {
86        let url = format!("{}/wallets/{}", self.api_base_url, self.wallet_id);
87
88        let response = self
89            .client
90            .get(&url)
91            .header("Authorization", self.get_auth_header())
92            .header("privy-app-id", &self.app_id)
93            .send()
94            .await?;
95
96        if !response.status().is_success() {
97            return Err(PrivyError::ApiError(response.status().as_u16()));
98        }
99
100        let wallet_info: WalletResponse = response.json().await?;
101
102        // For Solana wallets, the address is the public key
103        Pubkey::from_str(&wallet_info.address).map_err(|_| PrivyError::InvalidPublicKey)
104    }
105
106    /// Sign a transaction
107    ///
108    /// The transaction parameter should be a fully serialized Solana transaction
109    /// (including empty signature placeholders), not just the message bytes.
110    pub async fn sign_solana(&self, transaction: &[u8]) -> Result<Signature, PrivyError> {
111        let url = format!("{}/wallets/{}/rpc", self.api_base_url, self.wallet_id);
112
113        let request = SignTransactionRequest {
114            method: "signTransaction",
115            params: SignTransactionParams {
116                transaction: STANDARD.encode(transaction),
117                encoding: "base64",
118            },
119        };
120
121        let response = self
122            .client
123            .post(&url)
124            .header("Authorization", self.get_auth_header())
125            .header("privy-app-id", &self.app_id)
126            .header("Content-Type", "application/json")
127            .json(&request)
128            .send()
129            .await?;
130
131        if !response.status().is_success() {
132            let status = response.status().as_u16();
133            let error_text = response.text().await.unwrap_or_default();
134            return Err(PrivyError::ApiError(status));
135        }
136
137        let response_text = response.text().await?;
138
139        let sign_response: SignTransactionResponse = serde_json::from_str(&response_text)?;
140
141        // Decode the signed transaction from base64
142        let signed_tx_bytes = STANDARD.decode(&sign_response.data.signed_transaction)?;
143
144        // Deserialize the transaction to extract the signature
145        use solana_sdk::transaction::Transaction;
146        let signed_tx: Transaction =
147            bincode::deserialize(&signed_tx_bytes).map_err(|_| PrivyError::InvalidResponse)?;
148
149        // Get the first signature (which should be the one we just created)
150        if let Some(signature) = signed_tx.signatures.first() {
151            Ok(*signature)
152        } else {
153            Err(PrivyError::InvalidSignature)
154        }
155    }
156
157    pub async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, PrivyError> {
158        let signature = self.sign_solana(message).await?;
159        Ok(signature.as_ref().to_vec())
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn test_config_from_env() {
169        // Save existing env vars
170        let saved_app_id = std::env::var("PRIVY_APP_ID").ok();
171        let saved_app_secret = std::env::var("PRIVY_APP_SECRET").ok();
172        let saved_wallet_id = std::env::var("PRIVY_WALLET_ID").ok();
173
174        // Set test env vars
175        std::env::set_var("PRIVY_APP_ID", "test_app_id");
176        std::env::set_var("PRIVY_APP_SECRET", "test_secret");
177        std::env::set_var("PRIVY_WALLET_ID", "test_wallet");
178
179        let config = PrivyConfig::from_env();
180        assert_eq!(config.app_id, Some("test_app_id".to_string()));
181        assert_eq!(config.app_secret, Some("test_secret".to_string()));
182        assert_eq!(config.wallet_id, Some("test_wallet".to_string()));
183
184        // Restore original env vars
185        match saved_app_id {
186            Some(val) => std::env::set_var("PRIVY_APP_ID", val),
187            None => std::env::remove_var("PRIVY_APP_ID"),
188        }
189        match saved_app_secret {
190            Some(val) => std::env::set_var("PRIVY_APP_SECRET", val),
191            None => std::env::remove_var("PRIVY_APP_SECRET"),
192        }
193        match saved_wallet_id {
194            Some(val) => std::env::set_var("PRIVY_WALLET_ID", val),
195            None => std::env::remove_var("PRIVY_WALLET_ID"),
196        }
197    }
198
199    #[test]
200    fn test_config_merge() {
201        let config = PrivyConfig {
202            app_id: Some("env_id".to_string()),
203            app_secret: Some("env_secret".to_string()),
204            wallet_id: None,
205        };
206
207        let merged =
208            config.merge_with_cli(Some("cli_id".to_string()), None, Some("cli_wallet".to_string()));
209
210        assert_eq!(merged.app_id, Some("cli_id".to_string()));
211        assert_eq!(merged.app_secret, Some("env_secret".to_string()));
212        assert_eq!(merged.wallet_id, Some("cli_wallet".to_string()));
213    }
214}