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/// AWS KMS signer configuration
94#[derive(Clone, Serialize, Deserialize)]
95pub struct AwsKmsSignerConfig {
96    pub key_id_env: String,
97    pub public_key_env: String,
98    #[serde(default)]
99    pub region_env: Option<String>,
100}
101
102/// Fireblocks signer configuration
103#[derive(Clone, Serialize, Deserialize)]
104pub struct FireblocksSignerConfig {
105    pub api_key_env: String,
106    pub private_key_pem_env: String,
107    pub vault_account_id_env: String,
108    #[serde(default)]
109    pub asset_id: Option<String>,
110    #[serde(default)]
111    pub api_base_url: Option<String>,
112    #[serde(default)]
113    pub poll_interval_ms: Option<u64>,
114    #[serde(default)]
115    pub max_poll_attempts: Option<u32>,
116    #[serde(default)]
117    pub use_program_call: Option<bool>,
118}
119
120/// Signer type-specific configuration
121#[derive(Clone, Serialize, Deserialize)]
122#[serde(tag = "type", rename_all = "snake_case")]
123pub enum SignerTypeConfig {
124    /// Memory signer configuration
125    Memory {
126        #[serde(flatten)]
127        config: MemorySignerConfig,
128    },
129    /// Turnkey signer configuration
130    Turnkey {
131        #[serde(flatten)]
132        config: TurnkeySignerConfig,
133    },
134    /// Privy signer configuration
135    Privy {
136        #[serde(flatten)]
137        config: PrivySignerConfig,
138    },
139    /// Vault signer configuration
140    Vault {
141        #[serde(flatten)]
142        config: VaultSignerConfig,
143    },
144    /// AWS KMS signer configuration
145    AwsKms {
146        #[serde(flatten)]
147        config: AwsKmsSignerConfig,
148    },
149    /// Fireblocks signer configuration
150    Fireblocks {
151        #[serde(flatten)]
152        config: FireblocksSignerConfig,
153    },
154}
155
156impl SignerPoolConfig {
157    /// Load signer pool configuration from TOML file
158    pub fn load_config<P: AsRef<Path>>(path: P) -> Result<Self, KoraError> {
159        let contents = fs::read_to_string(path).map_err(|e| {
160            KoraError::InternalServerError(format!(
161                "Failed to read signer config file: {}",
162                sanitize_error!(e)
163            ))
164        })?;
165
166        let config: SignerPoolConfig = toml::from_str(&contents).map_err(|e| {
167            KoraError::ValidationError(format!(
168                "Failed to parse signers config TOML: {}",
169                sanitize_error!(e)
170            ))
171        })?;
172
173        config.validate_signer_config()?;
174
175        Ok(config)
176    }
177
178    /// Validate the signer pool configuration
179    pub fn validate_signer_config(&self) -> Result<(), KoraError> {
180        self.validate_signer_not_empty()?;
181
182        for (index, signer) in self.signers.iter().enumerate() {
183            signer.validate_individual_signer_config(index)?;
184        }
185
186        self.validate_signer_names()?;
187        self.validate_strategy_weights()?;
188
189        Ok(())
190    }
191
192    pub fn validate_signer_not_empty(&self) -> Result<(), KoraError> {
193        if self.signers.is_empty() {
194            return Err(KoraError::ValidationError(
195                "At least one signer must be configured".to_string(),
196            ));
197        }
198        Ok(())
199    }
200
201    pub fn validate_signer_names(&self) -> Result<(), KoraError> {
202        let mut names = std::collections::HashSet::new();
203        for signer in &self.signers {
204            if !names.insert(&signer.name) {
205                return Err(KoraError::ValidationError(format!(
206                    "Duplicate signer name: {}",
207                    signer.name
208                )));
209            }
210        }
211        Ok(())
212    }
213
214    pub fn validate_strategy_weights(&self) -> Result<(), KoraError> {
215        if matches!(self.signer_pool.strategy, SelectionStrategy::Weighted) {
216            for signer in &self.signers {
217                if let Some(weight) = signer.weight {
218                    if weight == 0 {
219                        return Err(KoraError::ValidationError(format!(
220                            "Signer '{}' has weight of 0 in weighted strategy",
221                            signer.name
222                        )));
223                    }
224                }
225            }
226        }
227        Ok(())
228    }
229}
230
231impl SignerConfig {
232    /// Build an external signer from configuration by resolving environment variables
233    pub async fn build_signer_from_config(config: &SignerConfig) -> Result<Signer, KoraError> {
234        match &config.config {
235            SignerTypeConfig::Memory { config: memory_config } => {
236                Self::build_memory_signer(memory_config, &config.name)
237            }
238            SignerTypeConfig::Turnkey { config: turnkey_config } => {
239                Self::build_turnkey_signer(turnkey_config, &config.name)
240            }
241            SignerTypeConfig::Privy { config: privy_config } => {
242                Self::build_privy_signer(privy_config, &config.name).await
243            }
244            SignerTypeConfig::Vault { config: vault_config } => {
245                Self::build_vault_signer(vault_config, &config.name)
246            }
247            SignerTypeConfig::AwsKms { config: aws_kms_config } => {
248                Self::build_aws_kms_signer(aws_kms_config, &config.name).await
249            }
250            SignerTypeConfig::Fireblocks { config: fireblocks_config } => {
251                Self::build_fireblocks_signer(fireblocks_config, &config.name).await
252            }
253        }
254    }
255
256    fn build_memory_signer(
257        config: &MemorySignerConfig,
258        signer_name: &str,
259    ) -> Result<Signer, KoraError> {
260        let private_key = get_env_var_for_signer(&config.private_key_env, signer_name)?;
261        Signer::from_memory(&private_key).map_err(|e| {
262            KoraError::SigningError(format!(
263                "Failed to create memory signer '{signer_name}': {}",
264                sanitize_error!(e)
265            ))
266        })
267    }
268
269    fn build_turnkey_signer(
270        config: &TurnkeySignerConfig,
271        signer_name: &str,
272    ) -> Result<Signer, KoraError> {
273        let api_public_key = get_env_var_for_signer(&config.api_public_key_env, signer_name)?;
274        let api_private_key = get_env_var_for_signer(&config.api_private_key_env, signer_name)?;
275        let organization_id = get_env_var_for_signer(&config.organization_id_env, signer_name)?;
276        let private_key_id = get_env_var_for_signer(&config.private_key_id_env, signer_name)?;
277        let public_key = get_env_var_for_signer(&config.public_key_env, signer_name)?;
278
279        Signer::from_turnkey(
280            api_public_key,
281            api_private_key,
282            organization_id,
283            private_key_id,
284            public_key,
285        )
286        .map_err(|e| {
287            KoraError::SigningError(format!(
288                "Failed to create Turnkey signer '{signer_name}': {}",
289                sanitize_error!(e)
290            ))
291        })
292    }
293
294    async fn build_privy_signer(
295        config: &PrivySignerConfig,
296        signer_name: &str,
297    ) -> Result<Signer, KoraError> {
298        let app_id = get_env_var_for_signer(&config.app_id_env, signer_name)?;
299        let app_secret = get_env_var_for_signer(&config.app_secret_env, signer_name)?;
300        let wallet_id = get_env_var_for_signer(&config.wallet_id_env, signer_name)?;
301
302        Signer::from_privy(app_id, app_secret, wallet_id).await.map_err(|e| {
303            KoraError::SigningError(format!(
304                "Failed to create Privy signer '{signer_name}': {}",
305                sanitize_error!(e)
306            ))
307        })
308    }
309
310    fn build_vault_signer(
311        config: &VaultSignerConfig,
312        signer_name: &str,
313    ) -> Result<Signer, KoraError> {
314        let vault_addr = get_env_var_for_signer(&config.vault_addr_env, signer_name)?;
315        let vault_token = get_env_var_for_signer(&config.vault_token_env, signer_name)?;
316        let key_name = get_env_var_for_signer(&config.key_name_env, signer_name)?;
317        let pubkey = get_env_var_for_signer(&config.pubkey_env, signer_name)?;
318
319        Signer::from_vault(vault_addr, vault_token, key_name, pubkey).map_err(|e| {
320            KoraError::SigningError(format!(
321                "Failed to create Vault signer '{signer_name}': {}",
322                sanitize_error!(e)
323            ))
324        })
325    }
326
327    async fn build_aws_kms_signer(
328        config: &AwsKmsSignerConfig,
329        signer_name: &str,
330    ) -> Result<Signer, KoraError> {
331        let key_id = get_env_var_for_signer(&config.key_id_env, signer_name)?;
332        let public_key = get_env_var_for_signer(&config.public_key_env, signer_name)?;
333        let region = config
334            .region_env
335            .as_ref()
336            .map(|env| get_env_var_for_signer(env, signer_name))
337            .transpose()?;
338
339        Signer::from_kms(key_id, public_key, region).await.map_err(|e| {
340            KoraError::SigningError(format!(
341                "Failed to create AWS KMS signer '{signer_name}': {}",
342                sanitize_error!(e)
343            ))
344        })
345    }
346
347    async fn build_fireblocks_signer(
348        config: &FireblocksSignerConfig,
349        signer_name: &str,
350    ) -> Result<Signer, KoraError> {
351        let api_key = get_env_var_for_signer(&config.api_key_env, signer_name)?;
352        let private_key_pem = get_env_var_for_signer(&config.private_key_pem_env, signer_name)?;
353        let vault_account_id = get_env_var_for_signer(&config.vault_account_id_env, signer_name)?;
354
355        let keychain_config = solana_keychain::FireblocksSignerConfig {
356            api_key,
357            private_key_pem,
358            vault_account_id,
359            asset_id: config.asset_id.clone(),
360            api_base_url: config.api_base_url.clone(),
361            poll_interval_ms: config.poll_interval_ms,
362            max_poll_attempts: config.max_poll_attempts,
363            use_program_call: config.use_program_call,
364        };
365
366        Signer::from_fireblocks(keychain_config).await.map_err(|e| {
367            KoraError::SigningError(format!(
368                "Failed to create Fireblocks signer '{signer_name}': {}",
369                sanitize_error!(e)
370            ))
371        })
372    }
373
374    /// Validate an individual signer configuration
375    pub fn validate_individual_signer_config(&self, index: usize) -> Result<(), KoraError> {
376        if self.name.is_empty() {
377            return Err(KoraError::ValidationError(format!(
378                "Signer at index {index} must have a non-empty name"
379            )));
380        }
381
382        match &self.config {
383            SignerTypeConfig::Memory { config } => Self::validate_memory_config(config, &self.name),
384            SignerTypeConfig::Turnkey { config } => {
385                Self::validate_turnkey_config(config, &self.name)
386            }
387            SignerTypeConfig::Privy { config } => Self::validate_privy_config(config, &self.name),
388            SignerTypeConfig::Vault { config } => Self::validate_vault_config(config, &self.name),
389            SignerTypeConfig::AwsKms { config } => {
390                Self::validate_aws_kms_config(config, &self.name)
391            }
392            SignerTypeConfig::Fireblocks { config } => {
393                Self::validate_fireblocks_config(config, &self.name)
394            }
395        }
396    }
397
398    fn validate_memory_config(
399        config: &MemorySignerConfig,
400        signer_name: &str,
401    ) -> Result<(), KoraError> {
402        if config.private_key_env.is_empty() {
403            return Err(KoraError::ValidationError(format!(
404                "Memory signer '{signer_name}' must specify non-empty private_key_env"
405            )));
406        }
407        get_env_var_for_signer(&config.private_key_env, signer_name)?;
408        Ok(())
409    }
410
411    fn validate_turnkey_config(
412        config: &TurnkeySignerConfig,
413        signer_name: &str,
414    ) -> Result<(), KoraError> {
415        let env_vars = [
416            ("api_public_key_env", &config.api_public_key_env),
417            ("api_private_key_env", &config.api_private_key_env),
418            ("organization_id_env", &config.organization_id_env),
419            ("private_key_id_env", &config.private_key_id_env),
420            ("public_key_env", &config.public_key_env),
421        ];
422
423        for (field_name, env_var) in env_vars {
424            if env_var.is_empty() {
425                return Err(KoraError::ValidationError(format!(
426                    "Turnkey signer '{signer_name}' must specify non-empty {field_name}"
427                )));
428            }
429            get_env_var_for_signer(env_var, signer_name)?;
430        }
431        Ok(())
432    }
433
434    fn validate_privy_config(
435        config: &PrivySignerConfig,
436        signer_name: &str,
437    ) -> Result<(), KoraError> {
438        let env_vars = [
439            ("app_id_env", &config.app_id_env),
440            ("app_secret_env", &config.app_secret_env),
441            ("wallet_id_env", &config.wallet_id_env),
442        ];
443
444        for (field_name, env_var) in env_vars {
445            if env_var.is_empty() {
446                return Err(KoraError::ValidationError(format!(
447                    "Privy signer '{signer_name}' must specify non-empty {field_name}"
448                )));
449            }
450            get_env_var_for_signer(env_var, signer_name)?;
451        }
452        Ok(())
453    }
454
455    fn validate_vault_config(
456        config: &VaultSignerConfig,
457        signer_name: &str,
458    ) -> Result<(), KoraError> {
459        let env_vars = [
460            ("vault_addr_env", &config.vault_addr_env),
461            ("vault_token_env", &config.vault_token_env),
462            ("key_name_env", &config.key_name_env),
463            ("pubkey_env", &config.pubkey_env),
464        ];
465
466        for (field_name, env_var) in env_vars {
467            if env_var.is_empty() {
468                return Err(KoraError::ValidationError(format!(
469                    "Vault signer '{signer_name}' must specify non-empty {field_name}"
470                )));
471            }
472            get_env_var_for_signer(env_var, signer_name)?;
473        }
474        Ok(())
475    }
476
477    fn validate_aws_kms_config(
478        config: &AwsKmsSignerConfig,
479        signer_name: &str,
480    ) -> Result<(), KoraError> {
481        let env_vars =
482            [("key_id_env", &config.key_id_env), ("public_key_env", &config.public_key_env)];
483
484        for (field_name, env_var) in env_vars {
485            if env_var.is_empty() {
486                return Err(KoraError::ValidationError(format!(
487                    "AWS KMS signer '{signer_name}' must specify non-empty {field_name}"
488                )));
489            }
490            get_env_var_for_signer(env_var, signer_name)?;
491        }
492        Ok(())
493    }
494
495    fn validate_fireblocks_config(
496        config: &FireblocksSignerConfig,
497        signer_name: &str,
498    ) -> Result<(), KoraError> {
499        let env_vars = [
500            ("api_key_env", &config.api_key_env),
501            ("private_key_pem_env", &config.private_key_pem_env),
502            ("vault_account_id_env", &config.vault_account_id_env),
503        ];
504
505        for (field_name, env_var) in env_vars {
506            if env_var.is_empty() {
507                return Err(KoraError::ValidationError(format!(
508                    "Fireblocks signer '{signer_name}' must specify non-empty {field_name}"
509                )));
510            }
511            get_env_var_for_signer(env_var, signer_name)?;
512        }
513        Ok(())
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use std::io::Write;
521    use tempfile::NamedTempFile;
522
523    #[test]
524    fn test_parse_valid_config() {
525        let toml_content = r#"
526[signer_pool]
527strategy = "round_robin"
528
529[[signers]]
530name = "memory_signer_1"
531type = "memory"
532private_key_env = "SIGNER_1_PRIVATE_KEY"
533weight = 1
534
535[[signers]]
536name = "turnkey_signer_1" 
537type = "turnkey"
538api_public_key_env = "TURNKEY_API_PUBLIC_KEY_1"
539api_private_key_env = "TURNKEY_API_PRIVATE_KEY_1"
540organization_id_env = "TURNKEY_ORG_ID_1"
541private_key_id_env = "TURNKEY_PRIVATE_KEY_ID_1"
542public_key_env = "TURNKEY_PUBLIC_KEY_1"
543weight = 2
544"#;
545
546        let config: SignerPoolConfig = toml::from_str(toml_content).unwrap();
547
548        assert_eq!(config.signers.len(), 2);
549        assert!(matches!(config.signer_pool.strategy, SelectionStrategy::RoundRobin));
550
551        // Check first signer
552        let signer1 = &config.signers[0];
553        assert_eq!(signer1.name, "memory_signer_1");
554        assert_eq!(signer1.weight, Some(1));
555
556        if let SignerTypeConfig::Memory { config } = &signer1.config {
557            assert_eq!(config.private_key_env, "SIGNER_1_PRIVATE_KEY");
558        } else {
559            panic!("Expected Memory signer config");
560        }
561    }
562
563    #[test]
564    fn test_validate_config_success() {
565        let config = SignerPoolConfig {
566            signer_pool: SignerPoolSettings { strategy: SelectionStrategy::RoundRobin },
567            signers: vec![SignerConfig {
568                name: "test_signer".to_string(),
569                weight: Some(1),
570                config: SignerTypeConfig::Memory {
571                    config: MemorySignerConfig {
572                        private_key_env: "KORA_VALIDATE_SUCCESS_KEY_99".to_string(),
573                    },
574                },
575            }],
576        };
577
578        std::env::set_var("KORA_VALIDATE_SUCCESS_KEY_99", "dummy");
579        assert!(config.validate_signer_config().is_ok());
580        assert!(config.validate_strategy_weights().is_ok());
581        std::env::remove_var("KORA_VALIDATE_SUCCESS_KEY_99");
582    }
583
584    #[test]
585    fn test_validate_config_empty_signers() {
586        let config = SignerPoolConfig {
587            signer_pool: SignerPoolSettings { strategy: SelectionStrategy::RoundRobin },
588            signers: vec![],
589        };
590
591        assert!(config.validate_signer_config().is_err());
592    }
593
594    #[test]
595    fn test_validate_config_duplicate_names() {
596        let config = SignerPoolConfig {
597            signer_pool: SignerPoolSettings { strategy: SelectionStrategy::RoundRobin },
598            signers: vec![
599                SignerConfig {
600                    name: "duplicate".to_string(),
601                    weight: Some(1),
602                    config: SignerTypeConfig::Memory {
603                        config: MemorySignerConfig {
604                            private_key_env: "TEST_PRIVATE_KEY_1".to_string(),
605                        },
606                    },
607                },
608                SignerConfig {
609                    name: "duplicate".to_string(),
610                    weight: Some(1),
611                    config: SignerTypeConfig::Memory {
612                        config: MemorySignerConfig {
613                            private_key_env: "TEST_PRIVATE_KEY_2".to_string(),
614                        },
615                    },
616                },
617            ],
618        };
619
620        assert!(config.validate_signer_config().is_err());
621    }
622
623    #[test]
624    fn test_load_signers_config() {
625        let toml_content = r#"
626[signer_pool]
627strategy = "round_robin"
628
629[[signers]]
630name = "test_signer"
631type = "memory"
632private_key_env = "KORA_LOAD_CONFIG_KEY_99"
633"#;
634
635        let mut temp_file = NamedTempFile::new().unwrap();
636        temp_file.write_all(toml_content.as_bytes()).unwrap();
637        temp_file.flush().unwrap();
638
639        std::env::set_var("KORA_LOAD_CONFIG_KEY_99", "dummy");
640        let config = SignerPoolConfig::load_config(temp_file.path()).unwrap();
641        assert_eq!(config.signers.len(), 1);
642        assert_eq!(config.signers[0].name, "test_signer");
643        std::env::remove_var("KORA_LOAD_CONFIG_KEY_99");
644    }
645
646    #[test]
647    fn test_validate_memory_config_missing_env_var() {
648        let _m = crate::tests::config_mock::ConfigMockBuilder::new().build_and_setup();
649
650        let config = SignerPoolConfig {
651            signer_pool: SignerPoolSettings { strategy: SelectionStrategy::RoundRobin },
652            signers: vec![SignerConfig {
653                name: "test_signer_missing".to_string(),
654                weight: Some(1),
655                config: SignerTypeConfig::Memory {
656                    config: MemorySignerConfig {
657                        private_key_env: "KORA_TEST_MISSING_KEY_12345".to_string(),
658                    },
659                },
660            }],
661        };
662
663        std::env::remove_var("KORA_TEST_MISSING_KEY_12345");
664        let result = config.validate_signer_config();
665        assert!(result.is_err());
666        assert!(matches!(result.unwrap_err(), KoraError::ValidationError(_)));
667    }
668
669    #[test]
670    fn test_validate_memory_config_env_var_present() {
671        let _m = crate::tests::config_mock::ConfigMockBuilder::new().build_and_setup();
672
673        let config = SignerPoolConfig {
674            signer_pool: SignerPoolSettings { strategy: SelectionStrategy::RoundRobin },
675            signers: vec![SignerConfig {
676                name: "test_signer_present".to_string(),
677                weight: Some(1),
678                config: SignerTypeConfig::Memory {
679                    config: MemorySignerConfig {
680                        private_key_env: "KORA_TEST_PRESENT_KEY_12345".to_string(),
681                    },
682                },
683            }],
684        };
685
686        std::env::set_var("KORA_TEST_PRESENT_KEY_12345", "dummy_value");
687        let result = config.validate_signer_config();
688        assert!(result.is_ok());
689        std::env::remove_var("KORA_TEST_PRESENT_KEY_12345");
690    }
691}