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)]
95#[serde(tag = "type", rename_all = "snake_case")]
96pub enum SignerTypeConfig {
97 Memory {
99 #[serde(flatten)]
100 config: MemorySignerConfig,
101 },
102 Turnkey {
104 #[serde(flatten)]
105 config: TurnkeySignerConfig,
106 },
107 Privy {
109 #[serde(flatten)]
110 config: PrivySignerConfig,
111 },
112 Vault {
114 #[serde(flatten)]
115 config: VaultSignerConfig,
116 },
117}
118
119impl SignerPoolConfig {
120 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 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 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 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 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}