Skip to main content

kobe_svm/
standard_wallet.rs

1//! Standard (non-HD) Solana wallet implementation.
2//!
3//! A standard wallet uses a single randomly generated private key,
4//! without mnemonic or HD derivation.
5//!
6use alloc::string::String;
7
8use ed25519_dalek::{SigningKey, VerifyingKey};
9use zeroize::{Zeroize, Zeroizing};
10
11use crate::Error;
12
13/// A standard Solana wallet with a single keypair.
14///
15/// This wallet type generates a random private key directly,
16/// without using a mnemonic or HD derivation.
17#[derive(Debug)]
18pub struct StandardWallet {
19    /// Ed25519 signing key.
20    signing_key: SigningKey,
21}
22
23impl StandardWallet {
24    /// Generate a new random wallet.
25    ///
26    /// Uses the operating system's cryptographically secure random number generator
27    /// via [`getrandom`].
28    ///
29    /// # Errors
30    ///
31    /// Returns an error if the system RNG is unavailable.
32    #[cfg(feature = "rand")]
33    pub fn generate() -> Result<Self, Error> {
34        let mut bytes = Zeroizing::new([0u8; 32]);
35        getrandom::fill(&mut *bytes)
36            .map_err(|_| Error::Derivation("system random number generator unavailable".into()))?;
37        let signing_key = SigningKey::from_bytes(&bytes);
38        Ok(Self { signing_key })
39    }
40
41    /// Create a wallet from raw 32-byte secret key.
42    #[must_use]
43    pub fn from_bytes(bytes: &[u8; 32]) -> Self {
44        let signing_key = SigningKey::from_bytes(bytes);
45        Self { signing_key }
46    }
47
48    /// Create a wallet from a hex-encoded secret key.
49    ///
50    /// # Errors
51    ///
52    /// Returns an error if the hex is invalid or key length is wrong.
53    pub fn from_hex(hex_key: &str) -> Result<Self, Error> {
54        let mut bytes = hex::decode(hex_key).map_err(|_| Error::InvalidHex)?;
55
56        if bytes.len() != 32 {
57            bytes.zeroize();
58            return Err(Error::Derivation(alloc::format!(
59                "expected 32 bytes, got {}",
60                bytes.len()
61            )));
62        }
63
64        let mut key_bytes = Zeroizing::new([0u8; 32]);
65        key_bytes.copy_from_slice(&bytes);
66        bytes.zeroize();
67        Ok(Self::from_bytes(&key_bytes))
68    }
69
70    /// Get the Solana address as Base58 encoded string.
71    #[inline]
72    #[must_use]
73    pub fn address(&self) -> String {
74        let verifying_key: VerifyingKey = self.signing_key.verifying_key();
75        bs58::encode(verifying_key.as_bytes()).into_string()
76    }
77
78    /// Get the secret key as raw bytes (zeroized on drop).
79    #[inline]
80    #[must_use]
81    pub fn secret_bytes(&self) -> Zeroizing<[u8; 32]> {
82        Zeroizing::new(*self.signing_key.as_bytes())
83    }
84
85    /// Get the secret key in hex format (zeroized on drop).
86    #[inline]
87    #[must_use]
88    pub fn secret_hex(&self) -> Zeroizing<String> {
89        Zeroizing::new(hex::encode(self.signing_key.as_bytes()))
90    }
91
92    /// Get the full keypair as base58-encoded string (64 bytes: secret 32B + public 32B).
93    ///
94    /// This is the standard format used by Phantom, Backpack, Solflare and other
95    /// Solana wallets for private key export/import.
96    #[inline]
97    #[must_use]
98    pub fn keypair_base58(&self) -> Zeroizing<String> {
99        let verifying_key: VerifyingKey = self.signing_key.verifying_key();
100        let mut keypair_bytes = [0u8; 64];
101        keypair_bytes[..32].copy_from_slice(self.signing_key.as_bytes());
102        keypair_bytes[32..].copy_from_slice(verifying_key.as_bytes());
103        let encoded = bs58::encode(&keypair_bytes).into_string();
104        // Zero out the temporary buffer
105        keypair_bytes.fill(0);
106        Zeroizing::new(encoded)
107    }
108
109    /// Get the public key in hex format.
110    #[inline]
111    #[must_use]
112    pub fn pubkey_hex(&self) -> String {
113        let verifying_key: VerifyingKey = self.signing_key.verifying_key();
114        hex::encode(verifying_key.as_bytes())
115    }
116}
117
118#[cfg(test)]
119#[allow(clippy::unwrap_used)]
120mod tests {
121    use super::*;
122
123    #[cfg(feature = "rand")]
124    #[test]
125    fn test_generate() {
126        let wallet = StandardWallet::generate().unwrap();
127        let address = wallet.address();
128
129        // Solana addresses are 32-44 characters in Base58
130        assert!(address.len() >= 32 && address.len() <= 44);
131    }
132
133    #[test]
134    fn test_from_bytes() {
135        let key = [1u8; 32];
136        let wallet = StandardWallet::from_bytes(&key);
137        let address = wallet.address();
138
139        assert!(address.len() >= 32 && address.len() <= 44);
140    }
141
142    #[test]
143    fn test_from_hex() {
144        let hex_key = "0101010101010101010101010101010101010101010101010101010101010101";
145        let wallet = StandardWallet::from_hex(hex_key).unwrap();
146        let address = wallet.address();
147
148        assert!(address.len() >= 32 && address.len() <= 44);
149    }
150
151    #[test]
152    fn test_deterministic() {
153        let key = [42u8; 32];
154        let wallet1 = StandardWallet::from_bytes(&key);
155        let wallet2 = StandardWallet::from_bytes(&key);
156
157        assert_eq!(wallet1.address(), wallet2.address());
158    }
159}