Skip to main content

kora_lib/signer/
pool.rs

1use crate::{
2    error::KoraError,
3    signer::config::{SelectionStrategy, SignerConfig, SignerPoolConfig},
4};
5use rand::Rng;
6use solana_keychain::{Signer, SolanaSigner};
7use solana_sdk::pubkey::Pubkey;
8use std::{
9    str::FromStr,
10    sync::{
11        atomic::{AtomicU64, AtomicUsize, Ordering},
12        Arc,
13    },
14};
15
16const DEFAULT_WEIGHT: u32 = 1;
17
18/// Metadata associated with a signer in the pool
19pub(crate) struct SignerWithMetadata {
20    /// Human-readable name for this signer
21    name: String,
22    /// The actual signer instance
23    signer: Arc<Signer>,
24    /// Weight for weighted selection (higher = more likely to be selected)
25    weight: u32,
26    /// Timestamp of last use (Unix timestamp in seconds)
27    last_used: AtomicU64,
28}
29
30impl Clone for SignerWithMetadata {
31    fn clone(&self) -> Self {
32        Self {
33            name: self.name.clone(),
34            signer: self.signer.clone(),
35            weight: self.weight,
36            last_used: AtomicU64::new(self.last_used.load(Ordering::Relaxed)),
37        }
38    }
39}
40
41impl SignerWithMetadata {
42    /// Create a new signer with metadata
43    pub(crate) fn new(name: String, signer: Arc<Signer>, weight: u32) -> Self {
44        Self { name, signer, weight, last_used: AtomicU64::new(0) }
45    }
46
47    /// Update the last used timestamp to current time
48    fn update_last_used(&self) {
49        let now = std::time::SystemTime::now()
50            .duration_since(std::time::UNIX_EPOCH)
51            .unwrap_or_default()
52            .as_secs();
53        self.last_used.store(now, Ordering::Relaxed);
54    }
55}
56
57/// A pool of signers with different selection strategies
58pub struct SignerPool {
59    /// List of signers with their metadata
60    signers: Vec<SignerWithMetadata>,
61    /// Strategy for selecting signers
62    strategy: SelectionStrategy,
63    /// Current index for round-robin selection
64    current_index: AtomicUsize,
65    /// Total weight of all signers in the pool
66    total_weight: u32,
67}
68
69/// Information about a signer for monitoring/debugging
70#[derive(Debug, Clone)]
71pub struct SignerInfo {
72    pub public_key: String,
73    pub name: String,
74    pub weight: u32,
75    pub last_used: u64, // Unix timestamp
76}
77
78impl SignerPool {
79    #[cfg(test)]
80    pub(crate) fn new(signers: Vec<SignerWithMetadata>) -> Self {
81        let total_weight: u32 = signers.iter().map(|s| s.weight).sum();
82
83        Self {
84            signers,
85            strategy: SelectionStrategy::RoundRobin,
86            current_index: AtomicUsize::new(0),
87            total_weight,
88        }
89    }
90
91    /// Create a new signer pool from configuration
92    pub async fn from_config(config: SignerPoolConfig) -> Result<Self, KoraError> {
93        if config.signers.is_empty() {
94            return Err(KoraError::ValidationError("Cannot create empty signer pool".to_string()));
95        }
96
97        let mut signers = Vec::new();
98
99        for signer_config in config.signers {
100            log::info!("Initializing signer: {}", signer_config.name);
101
102            let signer = SignerConfig::build_signer_from_config(&signer_config).await?;
103            let weight = signer_config.weight.unwrap_or(DEFAULT_WEIGHT);
104
105            signers.push(SignerWithMetadata::new(
106                signer_config.name.clone(),
107                Arc::new(signer),
108                weight,
109            ));
110
111            log::info!(
112                "Successfully initialized signer: {} (weight: {})",
113                signer_config.name,
114                weight
115            );
116        }
117
118        let total_weight: u32 = signers.iter().map(|s| s.weight).sum();
119
120        if matches!(config.signer_pool.strategy, SelectionStrategy::Weighted) && total_weight == 0 {
121            return Err(KoraError::InternalServerError(
122                "All signers have zero weight while using weighted selection strategy".to_string(),
123            ));
124        }
125
126        log::info!(
127            "Created signer pool with {} signers using {:?} strategy",
128            signers.len(),
129            config.signer_pool.strategy
130        );
131
132        Ok(Self {
133            signers,
134            strategy: config.signer_pool.strategy,
135            current_index: AtomicUsize::new(0),
136            total_weight,
137        })
138    }
139
140    /// Get the next signer according to the configured strategy
141    pub fn get_next_signer(&self) -> Result<Arc<Signer>, KoraError> {
142        if self.signers.is_empty() {
143            return Err(KoraError::InternalServerError("Signer pool is empty".to_string()));
144        }
145
146        let signer_meta = match self.strategy {
147            SelectionStrategy::RoundRobin => self.round_robin_select(),
148            SelectionStrategy::Random => self.random_select(),
149            SelectionStrategy::Weighted => self.weighted_select(),
150        }?;
151
152        signer_meta.update_last_used();
153        Ok(Arc::clone(&signer_meta.signer))
154    }
155
156    /// Round-robin selection strategy
157    fn round_robin_select(&self) -> Result<&SignerWithMetadata, KoraError> {
158        let index = self.current_index.fetch_add(1, Ordering::AcqRel);
159        let signer_index = index % self.signers.len();
160        Ok(&self.signers[signer_index])
161    }
162
163    /// Random selection strategy
164    fn random_select(&self) -> Result<&SignerWithMetadata, KoraError> {
165        let mut rng = rand::rng();
166        let index = rng.random_range(0..self.signers.len());
167        Ok(&self.signers[index])
168    }
169
170    /// Weighted selection strategy (weighted random)
171    fn weighted_select(&self) -> Result<&SignerWithMetadata, KoraError> {
172        let mut rng = rand::rng();
173        let mut target = rng.random_range(0..self.total_weight);
174
175        for signer in &self.signers {
176            if target < signer.weight {
177                return Ok(signer);
178            }
179            target -= signer.weight;
180        }
181
182        // Fallback to first signer (shouldn't happen)
183        Ok(&self.signers[0])
184    }
185
186    /// Get information about all signers in the pool
187    pub fn get_signers_info(&self) -> Vec<SignerInfo> {
188        self.signers
189            .iter()
190            .map(|s| SignerInfo {
191                public_key: s.signer.pubkey().to_string(),
192                name: s.name.clone(),
193                weight: s.weight,
194                last_used: s.last_used.load(Ordering::Relaxed),
195            })
196            .collect()
197    }
198
199    /// Get the number of signers in the pool
200    pub fn len(&self) -> usize {
201        self.signers.len()
202    }
203
204    /// Check if the pool is empty
205    pub fn is_empty(&self) -> bool {
206        self.signers.is_empty()
207    }
208
209    /// Get the configured strategy
210    pub fn strategy(&self) -> &SelectionStrategy {
211        &self.strategy
212    }
213
214    /// Get a signer by public key (for client consistency signer keys)
215    pub fn get_signer_by_pubkey(&self, pubkey: &str) -> Result<Arc<Signer>, KoraError> {
216        // Try to parse as Pubkey to validate format
217        let target_pubkey = Pubkey::from_str(pubkey).map_err(|_| {
218            KoraError::ValidationError(format!("Invalid signer signer key pubkey: {pubkey}"))
219        })?;
220
221        // Find signer with matching public key
222        let signer_meta =
223            self.signers.iter().find(|s| s.signer.pubkey() == target_pubkey).ok_or_else(|| {
224                KoraError::ValidationError(format!("Signer with pubkey {pubkey} not found in pool"))
225            })?;
226
227        signer_meta.update_last_used();
228        Ok(Arc::clone(&signer_meta.signer))
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use solana_sdk::signature::Keypair;
235
236    use super::*;
237    use std::collections::HashMap;
238
239    fn create_test_pool() -> SignerPool {
240        // Create test signers using external signer library
241        let keypair1 = Keypair::new();
242        let keypair2 = Keypair::new();
243
244        let external_signer1 =
245            solana_keychain::Signer::from_memory(&keypair1.to_base58_string()).unwrap();
246        let external_signer2 =
247            solana_keychain::Signer::from_memory(&keypair2.to_base58_string()).unwrap();
248
249        SignerPool {
250            signers: vec![
251                SignerWithMetadata::new("signer_1".to_string(), Arc::new(external_signer1), 1),
252                SignerWithMetadata::new("signer_2".to_string(), Arc::new(external_signer2), 2),
253            ],
254            strategy: SelectionStrategy::RoundRobin,
255            current_index: AtomicUsize::new(0),
256            total_weight: 3,
257        }
258    }
259
260    #[test]
261    fn test_round_robin_selection() {
262        let pool = create_test_pool();
263
264        // Test that round-robin cycles through signers
265        let mut selections = HashMap::new();
266        for _ in 0..100 {
267            let signer = pool.get_next_signer().unwrap();
268            *selections.entry(signer.pubkey().to_string()).or_insert(0) += 1;
269        }
270
271        // Should have selected both signers equally
272        assert_eq!(selections.len(), 2);
273        // Each signer should be selected 50 times
274        assert!(selections.values().all(|&count| count == 50));
275    }
276
277    #[test]
278    fn test_weighted_selection() {
279        let mut pool = create_test_pool();
280        pool.strategy = SelectionStrategy::Weighted;
281
282        // Store the public keys for comparison (signer_1 has weight 1, signer_2 has weight 2)
283        let signer1_pubkey = pool.signers[0].signer.pubkey().to_string();
284        let signer2_pubkey = pool.signers[1].signer.pubkey().to_string();
285
286        // Test weighted selection over many iterations
287        let mut selections = HashMap::new();
288        for _ in 0..300 {
289            let signer = pool.get_next_signer().unwrap();
290            *selections.entry(signer.pubkey().to_string()).or_insert(0) += 1;
291        }
292
293        // signer_2 has weight 2, signer_1 has weight 1
294        // So signer_2 should be selected ~2/3 of the time
295        let signer1_count = selections.get(&signer1_pubkey).unwrap_or(&0);
296        let signer2_count = selections.get(&signer2_pubkey).unwrap_or(&0);
297
298        // Allow some variance due to randomness
299        assert!(*signer2_count > *signer1_count);
300        assert!(*signer2_count > 150); // Should be around 200
301        assert!(*signer1_count > 50); // Should be around 100
302    }
303
304    #[test]
305    fn test_empty_pool() {
306        let pool = SignerPool {
307            signers: vec![],
308            strategy: SelectionStrategy::RoundRobin,
309            current_index: AtomicUsize::new(0),
310            total_weight: 0,
311        };
312
313        assert!(pool.get_next_signer().is_err());
314        assert!(pool.is_empty());
315        assert_eq!(pool.len(), 0);
316    }
317}