Skip to main content

kora_lib/signer/
config.rs

1use crate::{error::KoraError, sanitize_error, signer::utils::get_env_var_for_signer};
2use serde::{Deserialize, Serialize};
3use solana_keychain::Signer;
4use std::{fmt, fs, path::Path};
5
6/// Configuration for a pool of signers
7#[derive(Clone, Serialize, Deserialize)]
8pub struct SignerPoolConfig {
9    /// Signer pool configuration
10    pub signer_pool: SignerPoolSettings,
11    /// List of individual signer configurations
12    pub signers: Vec<SignerConfig>,
13}
14
15/// Settings for the signer pool behavior
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SignerPoolSettings {
18    /// Selection strategy for choosing signers
19    #[serde(default = "default_strategy")]
20    pub strategy: SelectionStrategy,
21}
22
23/// Available signer selection strategies
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum SelectionStrategy {
27    RoundRobin,
28    Random,
29    Weighted,
30}
31
32impl fmt::Display for SelectionStrategy {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        let s = match self {
35            SelectionStrategy::RoundRobin => "round_robin",
36            SelectionStrategy::Random => "random",
37            SelectionStrategy::Weighted => "weighted",
38        };
39        write!(f, "{s}")
40    }
41}
42
43fn default_strategy() -> SelectionStrategy {
44    SelectionStrategy::RoundRobin
45}
46
47/// Configuration for an individual signer
48#[derive(Clone, Serialize, Deserialize)]
49pub struct SignerConfig {
50    /// Human-readable name for this signer
51    pub name: String,
52    /// Weight for weighted selection strategy (optional, defaults to 1)
53    pub weight: Option<u32>,
54
55    /// Signer-specific configuration
56    #[serde(flatten)]
57    pub config: SignerTypeConfig,
58}
59
60/// Memory signer configuration (local keypair)
61#[derive(Clone, Serialize, Deserialize)]
62pub struct MemorySignerConfig {
63    pub private_key_env: String,
64}
65
66/// Turnkey signer configuration
67#[derive(Clone, Serialize, Deserialize)]
68pub struct TurnkeySignerConfig {
69    pub api_public_key_env: String,
70    pub api_private_key_env: String,
71    pub organization_id_env: String,
72    pub private_key_id_env: String,
73    pub public_key_env: String,
74}
75
76/// Privy signer configuration
77#[derive(Clone, Serialize, Deserialize)]
78pub struct PrivySignerConfig {
79    pub app_id_env: String,
80    pub app_secret_env: String,
81    pub wallet_id_env: String,
82}
83
84/// Vault signer configuration
85#[derive(Clone, Serialize, Deserialize)]
86pub struct VaultSignerConfig {
87    pub vault_addr_env: String,
88    pub vault_token_env: String,
89    pub key_name_env: String,
90    pub pubkey_env: String,
91}
92
93/// Signer type-specific configuration
94#[derive(Clone, Serialize, Deserialize)]
95#[serde(tag = "type", rename_all = "snake_case")]
96pub enum SignerTypeConfig {
97    /// Memory signer configuration
98    Memory {
99        #[serde(flatten)]
100        config: MemorySignerConfig,
101    },
102    /// Turnkey signer configuration
103    Turnkey {
104        #[serde(flatten)]
105        config: TurnkeySignerConfig,
106    },
107    /// Privy signer configuration
108    Privy {
109        #[serde(flatten)]
110        config: PrivySignerConfig,
111    },
112    /// Vault signer configuration
113    Vault {
114        #[serde(flatten)]
115        config: VaultSignerConfig,
116    },
117}
118
119impl SignerPoolConfig {
120    /// Load signer pool configuration from TOML file
121    pub fn load_config<P: AsRef<Path>>(path: P) -> Result<Self, KoraError> {
122        let contents = fs::read_to_string(path).map_err(|e| {
123            KoraError::InternalServerError(format!(
124                "Failed to read signer config file: {}",
125                sanitize_error!(e)
126            ))
127        })?;
128
129        let config: SignerPoolConfig = toml::from_str(&contents).map_err(|e| {
130            KoraError::ValidationError(format!(
131                "Failed to parse signers config TOML: {}",
132                sanitize_error!(e)
133            ))
134        })?;
135
136        config.validate_signer_config()?;
137
138        Ok(config)
139    }
140
141    /// Validate the signer pool configuration
142    pub fn validate_signer_config(&self) -> Result<(), KoraError> {
143        self.validate_signer_not_empty()?;
144
145        for (index, signer) in self.signers.iter().enumerate() {
146            signer.validate_individual_signer_config(index)?;
147        }
148
149        self.validate_signer_names()?;
150        self.validate_strategy_weights()?;
151
152        Ok(())
153    }
154
155    pub fn validate_signer_not_empty(&self) -> Result<(), KoraError> {
156        if self.signers.is_empty() {
157            return Err(KoraError::ValidationError(
158                "At least one signer must be configured".to_string(),
159            ));
160        }
161        Ok(())
162    }
163
164    pub fn validate_signer_names(&self) -> Result<(), KoraError> {
165        let mut names = std::collections::HashSet::new();
166        for signer in &self.signers {
167            if !names.insert(&signer.name) {
168                return Err(KoraError::ValidationError(format!(
169                    "Duplicate signer name: {}",
170                    signer.name
171                )));
172            }
173        }
174        Ok(())
175    }
176
177    pub fn validate_strategy_weights(&self) -> Result<(), KoraError> {
178        if matches!(self.signer_pool.strategy, SelectionStrategy::Weighted) {
179            for signer in &self.signers {
180                if let Some(weight) = signer.weight {
181                    if weight == 0 {
182                        return Err(KoraError::ValidationError(format!(
183                            "Signer '{}' has weight of 0 in weighted strategy",
184                            signer.name
185                        )));
186                    }
187                }
188            }
189        }
190        Ok(())
191    }
192}
193
194impl SignerConfig {
195    /// Build an external signer from configuration by resolving environment variables
196    pub async fn build_signer_from_config(config: &SignerConfig) -> Result<Signer, KoraError> {
197        match &config.config {
198            SignerTypeConfig::Memory { config: memory_config } => {
199                Self::build_memory_signer(memory_config, &config.name)
200            }
201            SignerTypeConfig::Turnkey { config: turnkey_config } => {
202                Self::build_turnkey_signer(turnkey_config, &config.name)
203            }
204            SignerTypeConfig::Privy { config: privy_config } => {
205                Self::build_privy_signer(privy_config, &config.name).await
206            }
207            SignerTypeConfig::Vault { config: vault_config } => {
208                Self::build_vault_signer(vault_config, &config.name)
209            }
210        }
211    }
212
213    fn build_memory_signer(
214        config: &MemorySignerConfig,
215        signer_name: &str,
216    ) -> Result<Signer, KoraError> {
217        let private_key = get_env_var_for_signer(&config.private_key_env, signer_name)?;
218        Signer::from_memory(&private_key).map_err(|e| {
219            KoraError::SigningError(format!(
220                "Failed to create memory signer '{signer_name}': {}",
221                sanitize_error!(e)
222            ))
223        })
224    }
225
226    fn build_turnkey_signer(
227        config: &TurnkeySignerConfig,
228        signer_name: &str,
229    ) -> Result<Signer, KoraError> {
230        let api_public_key = get_env_var_for_signer(&config.api_public_key_env, signer_name)?;
231        let api_private_key = get_env_var_for_signer(&config.api_private_key_env, signer_name)?;
232        let organization_id = get_env_var_for_signer(&config.organization_id_env, signer_name)?;
233        let private_key_id = get_env_var_for_signer(&config.private_key_id_env, signer_name)?;
234        let public_key = get_env_var_for_signer(&config.public_key_env, signer_name)?;
235
236        Signer::from_turnkey(
237            api_public_key,
238            api_private_key,
239            organization_id,
240            private_key_id,
241            public_key,
242        )
243        .map_err(|e| {
244            KoraError::SigningError(format!(
245                "Failed to create Turnkey signer '{signer_name}': {}",
246                sanitize_error!(e)
247            ))
248        })
249    }
250
251    async fn build_privy_signer(
252        config: &PrivySignerConfig,
253        signer_name: &str,
254    ) -> Result<Signer, KoraError> {
255        let app_id = get_env_var_for_signer(&config.app_id_env, signer_name)?;
256        let app_secret = get_env_var_for_signer(&config.app_secret_env, signer_name)?;
257        let wallet_id = get_env_var_for_signer(&config.wallet_id_env, signer_name)?;
258
259        Signer::from_privy(app_id, app_secret, wallet_id).await.map_err(|e| {
260            KoraError::SigningError(format!(
261                "Failed to create Privy signer '{signer_name}': {}",
262                sanitize_error!(e)
263            ))
264        })
265    }
266
267    fn build_vault_signer(
268        config: &VaultSignerConfig,
269        signer_name: &str,
270    ) -> Result<Signer, KoraError> {
271        let vault_addr = get_env_var_for_signer(&config.vault_addr_env, signer_name)?;
272        let vault_token = get_env_var_for_signer(&config.vault_token_env, signer_name)?;
273        let key_name = get_env_var_for_signer(&config.key_name_env, signer_name)?;
274        let pubkey = get_env_var_for_signer(&config.pubkey_env, signer_name)?;
275
276        Signer::from_vault(vault_addr, vault_token, key_name, pubkey).map_err(|e| {
277            KoraError::SigningError(format!(
278                "Failed to create Vault signer '{signer_name}': {}",
279                sanitize_error!(e)
280            ))
281        })
282    }
283
284    /// Validate an individual signer configuration
285    pub fn validate_individual_signer_config(&self, index: usize) -> Result<(), KoraError> {
286        if self.name.is_empty() {
287            return Err(KoraError::ValidationError(format!(
288                "Signer at index {index} must have a non-empty name"
289            )));
290        }
291
292        match &self.config {
293            SignerTypeConfig::Memory { config } => Self::validate_memory_config(config, &self.name),
294            SignerTypeConfig::Turnkey { config } => {
295                Self::validate_turnkey_config(config, &self.name)
296            }
297            SignerTypeConfig::Privy { config } => Self::validate_privy_config(config, &self.name),
298            SignerTypeConfig::Vault { config } => Self::validate_vault_config(config, &self.name),
299        }
300    }
301
302    fn validate_memory_config(
303        config: &MemorySignerConfig,
304        signer_name: &str,
305    ) -> Result<(), KoraError> {
306        if config.private_key_env.is_empty() {
307            return Err(KoraError::ValidationError(format!(
308                "Memory signer '{signer_name}' must specify non-empty private_key_env"
309            )));
310        }
311        Ok(())
312    }
313
314    fn validate_turnkey_config(
315        config: &TurnkeySignerConfig,
316        signer_name: &str,
317    ) -> Result<(), KoraError> {
318        let env_vars = [
319            ("api_public_key_env", &config.api_public_key_env),
320            ("api_private_key_env", &config.api_private_key_env),
321            ("organization_id_env", &config.organization_id_env),
322            ("private_key_id_env", &config.private_key_id_env),
323            ("public_key_env", &config.public_key_env),
324        ];
325
326        for (field_name, env_var) in env_vars {
327            if env_var.is_empty() {
328                return Err(KoraError::ValidationError(format!(
329                    "Turnkey signer '{signer_name}' must specify non-empty {field_name}"
330                )));
331            }
332        }
333        Ok(())
334    }
335
336    fn validate_privy_config(
337        config: &PrivySignerConfig,
338        signer_name: &str,
339    ) -> Result<(), KoraError> {
340        let env_vars = [
341            ("app_id_env", &config.app_id_env),
342            ("app_secret_env", &config.app_secret_env),
343            ("wallet_id_env", &config.wallet_id_env),
344        ];
345
346        for (field_name, env_var) in env_vars {
347            if env_var.is_empty() {
348                return Err(KoraError::ValidationError(format!(
349                    "Privy signer '{signer_name}' must specify non-empty {field_name}"
350                )));
351            }
352        }
353        Ok(())
354    }
355
356    fn validate_vault_config(
357        config: &VaultSignerConfig,
358        signer_name: &str,
359    ) -> Result<(), KoraError> {
360        let env_vars = [
361            ("vault_addr_env", &config.vault_addr_env),
362            ("vault_token_env", &config.vault_token_env),
363            ("key_name_env", &config.key_name_env),
364            ("pubkey_env", &config.pubkey_env),
365        ];
366
367        for (field_name, env_var) in env_vars {
368            if env_var.is_empty() {
369                return Err(KoraError::ValidationError(format!(
370                    "Vault signer '{signer_name}' must specify non-empty {field_name}"
371                )));
372            }
373        }
374        Ok(())
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use std::io::Write;
382    use tempfile::NamedTempFile;
383
384    #[test]
385    fn test_parse_valid_config() {
386        let toml_content = r#"
387[signer_pool]
388strategy = "round_robin"
389
390[[signers]]
391name = "memory_signer_1"
392type = "memory"
393private_key_env = "SIGNER_1_PRIVATE_KEY"
394weight = 1
395
396[[signers]]
397name = "turnkey_signer_1" 
398type = "turnkey"
399api_public_key_env = "TURNKEY_API_PUBLIC_KEY_1"
400api_private_key_env = "TURNKEY_API_PRIVATE_KEY_1"
401organization_id_env = "TURNKEY_ORG_ID_1"
402private_key_id_env = "TURNKEY_PRIVATE_KEY_ID_1"
403public_key_env = "TURNKEY_PUBLIC_KEY_1"
404weight = 2
405"#;
406
407        let config: SignerPoolConfig = toml::from_str(toml_content).unwrap();
408
409        assert_eq!(config.signers.len(), 2);
410        assert!(matches!(config.signer_pool.strategy, SelectionStrategy::RoundRobin));
411
412        // Check first signer
413        let signer1 = &config.signers[0];
414        assert_eq!(signer1.name, "memory_signer_1");
415        assert_eq!(signer1.weight, Some(1));
416
417        if let SignerTypeConfig::Memory { config } = &signer1.config {
418            assert_eq!(config.private_key_env, "SIGNER_1_PRIVATE_KEY");
419        } else {
420            panic!("Expected Memory signer config");
421        }
422    }
423
424    #[test]
425    fn test_validate_config_success() {
426        let config = SignerPoolConfig {
427            signer_pool: SignerPoolSettings { strategy: SelectionStrategy::RoundRobin },
428            signers: vec![SignerConfig {
429                name: "test_signer".to_string(),
430                weight: Some(1),
431                config: SignerTypeConfig::Memory {
432                    config: MemorySignerConfig { private_key_env: "TEST_PRIVATE_KEY".to_string() },
433                },
434            }],
435        };
436
437        assert!(config.validate_signer_config().is_ok());
438        assert!(config.validate_strategy_weights().is_ok());
439    }
440
441    #[test]
442    fn test_validate_config_empty_signers() {
443        let config = SignerPoolConfig {
444            signer_pool: SignerPoolSettings { strategy: SelectionStrategy::RoundRobin },
445            signers: vec![],
446        };
447
448        assert!(config.validate_signer_config().is_err());
449    }
450
451    #[test]
452    fn test_validate_config_duplicate_names() {
453        let config = SignerPoolConfig {
454            signer_pool: SignerPoolSettings { strategy: SelectionStrategy::RoundRobin },
455            signers: vec![
456                SignerConfig {
457                    name: "duplicate".to_string(),
458                    weight: Some(1),
459                    config: SignerTypeConfig::Memory {
460                        config: MemorySignerConfig {
461                            private_key_env: "TEST_PRIVATE_KEY_1".to_string(),
462                        },
463                    },
464                },
465                SignerConfig {
466                    name: "duplicate".to_string(),
467                    weight: Some(1),
468                    config: SignerTypeConfig::Memory {
469                        config: MemorySignerConfig {
470                            private_key_env: "TEST_PRIVATE_KEY_2".to_string(),
471                        },
472                    },
473                },
474            ],
475        };
476
477        assert!(config.validate_signer_config().is_err());
478    }
479
480    #[test]
481    fn test_load_signers_config() {
482        let toml_content = r#"
483[signer_pool]
484strategy = "round_robin"
485
486[[signers]]
487name = "test_signer"
488type = "memory"
489private_key_env = "TEST_PRIVATE_KEY"
490"#;
491
492        let mut temp_file = NamedTempFile::new().unwrap();
493        temp_file.write_all(toml_content.as_bytes()).unwrap();
494        temp_file.flush().unwrap();
495
496        let config = SignerPoolConfig::load_config(temp_file.path()).unwrap();
497        assert_eq!(config.signers.len(), 1);
498        assert_eq!(config.signers[0].name, "test_signer");
499    }
500}