Skip to main content

kobe_svm/
deriver.rs

1//! Solana address derivation from HD wallet.
2
3use alloc::string::String;
4use alloc::vec::Vec;
5use core::ops::Deref;
6
7use kobe_primitives::slip10::DerivedEd25519Key;
8use kobe_primitives::{
9    // Anonymous trait import so `style.path(i)` resolves through the shared
10    // trait; the local `DerivationStyle` enum keeps the bare name.
11    DerivationStyle as _,
12    Derive,
13    DeriveError,
14    DerivedAccount,
15    DerivedPublicKey,
16    Wallet,
17    derive_range,
18};
19use zeroize::Zeroizing;
20
21use crate::derivation_style::DerivationStyle;
22
23/// A Solana-specific derived account — [`DerivedAccount`] plus Phantom-style keypair.
24///
25/// Wraps the unified [`DerivedAccount`] (path, 32-byte private key, 32-byte
26/// public key, Base58 address) and adds the Solana-native 64-byte keypair
27/// (`secret || public`) Base58-encoded for Phantom / Backpack / Solflare
28/// import.
29///
30/// Implements `Deref<Target = DerivedAccount>`, so all shared accessors
31/// (`address()`, `public_key_bytes()`, etc.) are available directly.
32#[derive(Clone)]
33pub struct SvmAccount {
34    inner: DerivedAccount,
35    keypair_base58: Zeroizing<String>,
36}
37
38impl core::fmt::Debug for SvmAccount {
39    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
40        f.debug_struct("SvmAccount")
41            .field("inner", &self.inner)
42            .field("keypair_base58", &"[REDACTED]")
43            .finish()
44    }
45}
46
47impl SvmAccount {
48    /// Full keypair in Base58 format (64 bytes: secret 32 B + public 32 B), zeroized on drop.
49    ///
50    /// Standard import format used by Phantom, Backpack, and Solflare.
51    #[inline]
52    #[must_use]
53    pub const fn keypair_base58(&self) -> &Zeroizing<String> {
54        &self.keypair_base58
55    }
56
57    /// The underlying unified [`DerivedAccount`].
58    #[inline]
59    #[must_use]
60    pub const fn as_derived_account(&self) -> &DerivedAccount {
61        &self.inner
62    }
63
64    /// Consume and yield the underlying [`DerivedAccount`], dropping the
65    /// Solana-specific keypair field.
66    #[inline]
67    #[must_use]
68    pub fn into_derived_account(self) -> DerivedAccount {
69        self.inner
70    }
71}
72
73impl Deref for SvmAccount {
74    type Target = DerivedAccount;
75
76    #[inline]
77    fn deref(&self) -> &Self::Target {
78        &self.inner
79    }
80}
81
82impl From<SvmAccount> for DerivedAccount {
83    #[inline]
84    fn from(svm: SvmAccount) -> Self {
85        svm.inner
86    }
87}
88
89/// Solana address deriver from a unified wallet seed.
90///
91/// This deriver takes a seed from [`kobe_primitives::Wallet`] and derives
92/// Solana addresses following BIP44/SLIP-0010 standards.
93#[derive(Debug)]
94pub struct Deriver<'a> {
95    /// Reference to the wallet for seed access.
96    wallet: &'a Wallet,
97}
98
99impl<'a> Deriver<'a> {
100    /// Create a new Solana deriver from a wallet.
101    #[inline]
102    #[must_use]
103    pub const fn new(wallet: &'a Wallet) -> Self {
104        Self { wallet }
105    }
106
107    /// Derive a Solana account using the Standard derivation style.
108    ///
109    /// Uses path `m/44'/501'/{index}'/0'` (Phantom, Backpack, Solflare).
110    ///
111    /// # Errors
112    ///
113    /// Returns an error if derivation fails.
114    #[inline]
115    pub fn derive(&self, index: u32) -> Result<SvmAccount, DeriveError> {
116        self.derive_with(DerivationStyle::Standard, index)
117    }
118
119    /// Derive a Solana account with a specific [`DerivationStyle`].
120    ///
121    /// Supported path layouts:
122    /// - **Standard** (Phantom/Backpack): `m/44'/501'/{index}'/0'`
123    /// - **Trust**: `m/44'/501'/{index}'`
124    /// - **Ledger Live**: `m/44'/501'/{index}'/0'/0'`
125    /// - **Legacy**: `m/501'/{index}'/0'/0'`
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if derivation fails.
130    pub fn derive_with(
131        &self,
132        style: DerivationStyle,
133        index: u32,
134    ) -> Result<SvmAccount, DeriveError> {
135        let path = style.path(index);
136        let derived = self.wallet.derive_ed25519(&path)?;
137        Ok(build_svm_account(&derived, path))
138    }
139
140    /// Derive multiple accounts with a specific [`DerivationStyle`].
141    ///
142    /// # Errors
143    ///
144    /// Returns an error if any derivation fails.
145    pub fn derive_many_with(
146        &self,
147        style: DerivationStyle,
148        start: u32,
149        count: u32,
150    ) -> Result<Vec<SvmAccount>, DeriveError> {
151        derive_range(start, count, |i| self.derive_with(style, i))
152    }
153
154    /// Derive an account at a custom SLIP-0010 path.
155    ///
156    /// **Note**: Ed25519 (Solana) only supports hardened derivation;
157    /// all path components are treated as hardened.
158    ///
159    /// # Errors
160    ///
161    /// Returns an error if derivation fails.
162    pub fn derive_at(&self, path: &str) -> Result<SvmAccount, DeriveError> {
163        let derived = self.wallet.derive_ed25519(path)?;
164        Ok(build_svm_account(&derived, String::from(path)))
165    }
166}
167
168impl Derive for Deriver<'_> {
169    type Account = SvmAccount;
170    type Error = DeriveError;
171
172    fn derive(&self, index: u32) -> Result<SvmAccount, DeriveError> {
173        self.derive_with(DerivationStyle::Standard, index)
174    }
175
176    fn derive_path(&self, path: &str) -> Result<SvmAccount, DeriveError> {
177        self.derive_at(path)
178    }
179}
180
181impl AsRef<DerivedAccount> for SvmAccount {
182    #[inline]
183    fn as_ref(&self) -> &DerivedAccount {
184        &self.inner
185    }
186}
187
188/// Build an [`SvmAccount`] from a derived SLIP-10 key and path string.
189fn build_svm_account(derived: &DerivedEd25519Key, path: String) -> SvmAccount {
190    let public_key_bytes = derived.public_key_bytes();
191    let sk_bytes = derived.private_key_bytes();
192
193    let mut keypair_bytes = Zeroizing::new([0u8; 64]);
194    let (left, right) = keypair_bytes.split_at_mut(32);
195    left.copy_from_slice(sk_bytes.as_slice());
196    right.copy_from_slice(&public_key_bytes);
197    let keypair_b58 = bs58::encode(&*keypair_bytes).into_string();
198
199    let inner = DerivedAccount::new(
200        path,
201        sk_bytes,
202        DerivedPublicKey::Ed25519(public_key_bytes),
203        bs58::encode(&public_key_bytes).into_string(),
204    );
205
206    SvmAccount {
207        inner,
208        keypair_base58: Zeroizing::new(keypair_b58),
209    }
210}
211
212#[cfg(test)]
213#[allow(clippy::indexing_slicing, reason = "test assertions")]
214mod tests {
215    use kobe_primitives::DeriveExt;
216
217    use super::*;
218
219    /// Canonical BIP-39 test mnemonic (12 × `abandon` + `about`).
220    const MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
221
222    fn test_wallet() -> Wallet {
223        Wallet::from_mnemonic(MNEMONIC, None).unwrap()
224    }
225
226    /// Known-answer test on the canonical BIP-39 `abandon…about` mnemonic
227    /// at the Phantom / Solflare default path `m/44'/501'/{i}'/0'`.
228    ///
229    /// Cross-verified with an independent Node.js pipeline
230    /// (`bip39 → ed25519-hd-key SLIP-10 → tweetnacl key pair → base58`)
231    /// per the Solana wallet adapter defaults documented at
232    /// <https://docs.phantom.app/>.
233    #[test]
234    fn debug_redacts_keypair_base58() {
235        let acct = Deriver::new(&test_wallet()).derive(0).unwrap();
236        let kp = acct.keypair_base58().as_str().to_owned();
237        let dbg = format!("{acct:?}");
238        assert!(dbg.contains("[REDACTED]"));
239        assert!(!dbg.contains(&kp), "Debug must not leak keypair: {dbg}");
240    }
241
242    #[test]
243    fn kat_solana_phantom_abandon_index0() {
244        let acct = Deriver::new(&test_wallet()).derive(0).unwrap();
245        assert_eq!(acct.path(), "m/44'/501'/0'/0'");
246        assert_eq!(
247            acct.address(),
248            "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk"
249        );
250        assert_eq!(
251            acct.private_key_hex().as_str(),
252            "37df573b3ac4ad5b522e064e25b63ea16bcbe79d449e81a0268d1047948bb445"
253        );
254    }
255
256    #[test]
257    fn kat_solana_phantom_abandon_index1() {
258        let acct = Deriver::new(&test_wallet()).derive(1).unwrap();
259        assert_eq!(acct.path(), "m/44'/501'/1'/0'");
260        assert_eq!(
261            acct.address(),
262            "Hh8QwFUA6MtVu1qAoq12ucvFHNwCcVTV7hpWjeY1Hztb"
263        );
264        assert_eq!(
265            acct.private_key_hex().as_str(),
266            "ba5e7b6e3680b4eb81db8e54c8e466b2e9a899355888403355d858ab985d2fc4"
267        );
268    }
269
270    /// Each derivation style must produce a distinct path AND a distinct
271    /// address — guards against silent path collisions.
272    #[test]
273    fn derivation_styles_produce_distinct_addresses() {
274        let w = test_wallet();
275        let d = Deriver::new(&w);
276        let standard = d.derive_with(DerivationStyle::Standard, 0).unwrap();
277        let trust = d.derive_with(DerivationStyle::Trust, 0).unwrap();
278        let ledger = d.derive_with(DerivationStyle::LedgerLive, 0).unwrap();
279        let legacy = d.derive_with(DerivationStyle::Legacy, 0).unwrap();
280        assert_eq!(standard.path(), "m/44'/501'/0'/0'");
281        assert_eq!(trust.path(), "m/44'/501'/0'");
282        assert_eq!(ledger.path(), "m/44'/501'/0'/0'/0'");
283        assert_eq!(legacy.path(), "m/501'/0'/0'/0'");
284        assert_ne!(standard.address(), trust.address());
285        assert_ne!(standard.address(), ledger.address());
286        assert_ne!(standard.address(), legacy.address());
287        assert_ne!(trust.address(), ledger.address());
288    }
289
290    /// `derive_many` must agree with scalar `derive` for every index.
291    #[test]
292    fn derive_many_matches_individual() {
293        let w = test_wallet();
294        let d = Deriver::new(&w);
295        let batch = d.derive_many(0, 3).unwrap();
296        let single: Vec<_> = (0..3).map(|i| d.derive(i).unwrap()).collect();
297        for (b, s) in batch.iter().zip(single.iter()) {
298            assert_eq!(b.address(), s.address());
299            assert_eq!(b.path(), s.path());
300            assert_eq!(b.keypair_base58(), s.keypair_base58());
301        }
302    }
303
304    /// `keypair_base58` must be the solana-CLI-compatible 64-byte
305    /// (private || public) base58 encoding; decoding it back must yield
306    /// the same 32+32 layout.
307    #[test]
308    fn keypair_base58_matches_64_byte_layout() {
309        let w = test_wallet();
310        let acct = Deriver::new(&w).derive(0).unwrap();
311        let decoded = bs58::decode(acct.keypair_base58().as_str())
312            .into_vec()
313            .unwrap();
314        assert_eq!(decoded.len(), 64);
315        assert_eq!(&decoded[..32], acct.private_key_bytes().as_slice());
316        assert_eq!(&decoded[32..], acct.public_key_bytes());
317    }
318}