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#[derive(Clone, Serialize, Deserialize)]
8pub struct SignerPoolConfig {
9 pub signer_pool: SignerPoolSettings,
11 pub signers: Vec<SignerConfig>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SignerPoolSettings {
18 #[serde(default = "default_strategy")]
20 pub strategy: SelectionStrategy,
21}
22
23#[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#[derive(Clone, Serialize, Deserialize)]
49pub struct SignerConfig {
50 pub name: String,
52 pub weight: Option<u32>,
54
55 #[serde(flatten)]
57 pub config: SignerTypeConfig,
58}
59
60#[derive(Clone, Serialize, Deserialize)]
62pub struct MemorySignerConfig {
63 pub private_key_env: String,
64}
65
66#[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#[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#[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#[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#[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#[derive(Clone, Serialize, Deserialize)]
122#[serde(tag = "type", rename_all = "snake_case")]
123pub enum SignerTypeConfig {
124 Memory {
126 #[serde(flatten)]
127 config: MemorySignerConfig,
128 },
129 Turnkey {
131 #[serde(flatten)]
132 config: TurnkeySignerConfig,
133 },
134 Privy {
136 #[serde(flatten)]
137 config: PrivySignerConfig,
138 },
139 Vault {
141 #[serde(flatten)]
142 config: VaultSignerConfig,
143 },
144 AwsKms {
146 #[serde(flatten)]
147 config: AwsKmsSignerConfig,
148 },
149 Fireblocks {
151 #[serde(flatten)]
152 config: FireblocksSignerConfig,
153 },
154}
155
156impl SignerPoolConfig {
157 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 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 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 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 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}