kobe_primitives/derive.rs
1//! Unified derivation trait and account type.
2//!
3//! All chain-specific derivers implement [`Derive`], providing a consistent
4//! API surface across chains. [`DeriveExt`] is automatically implemented for
5//! all `Derive` types, providing batch derivation via [`derive_many`](DeriveExt::derive_many).
6
7use alloc::string::String;
8use alloc::vec::Vec;
9
10use zeroize::Zeroizing;
11
12use crate::DeriveError;
13
14/// A derived account from any chain.
15///
16/// Contains the derivation path, key material, and on-chain address.
17/// The private key is zeroized on drop.
18#[derive(Debug, Clone)]
19#[non_exhaustive]
20pub struct DerivedAccount {
21 /// BIP-32/SLIP-10 derivation path used (e.g. `m/44'/60'/0'/0/0`).
22 pub path: String,
23 /// Private key in hex (zeroized on drop).
24 pub private_key: Zeroizing<String>,
25 /// Public key in hex.
26 pub public_key: String,
27 /// On-chain address in the chain's native format.
28 pub address: String,
29}
30
31impl DerivedAccount {
32 /// Create a new derived account.
33 #[must_use]
34 pub const fn new(
35 path: String,
36 private_key: Zeroizing<String>,
37 public_key: String,
38 address: String,
39 ) -> Self {
40 Self {
41 path,
42 private_key,
43 public_key,
44 address,
45 }
46 }
47
48 /// Decode the hex-encoded private key into raw 32-byte material.
49 ///
50 /// Every chain deriver in this workspace produces a 32-byte scalar
51 /// (secp256k1 for EVM/BTC/Cosmos/Tron/Spark/Filecoin/XRPL/Nostr,
52 /// Ed25519 for SVM/SUI/TON/Aptos), so the output is fixed-length.
53 /// The returned buffer is zeroized on drop.
54 ///
55 /// # Errors
56 ///
57 /// Returns an error if the stored hex is malformed or not exactly
58 /// 32 bytes. Derivers in this workspace never produce malformed data,
59 /// so this error is unexpected in normal use.
60 pub fn private_key_bytes(&self) -> Result<Zeroizing<[u8; 32]>, DeriveError> {
61 let mut buf = Zeroizing::new([0u8; 32]);
62 hex::decode_to_slice(self.private_key.as_str(), buf.as_mut_slice())
63 .map_err(|e| DeriveError::InvalidHex(alloc::format!("private_key: {e}")))?;
64 Ok(buf)
65 }
66
67 /// Decode the hex-encoded public key into raw bytes.
68 ///
69 /// Length is chain-specific: 33 for compressed secp256k1, 65 for
70 /// uncompressed, 32 for Ed25519 / x-only secp256k1.
71 ///
72 /// # Errors
73 ///
74 /// Returns an error if the stored hex is malformed.
75 pub fn public_key_bytes(&self) -> Result<Vec<u8>, DeriveError> {
76 hex::decode(&self.public_key)
77 .map_err(|e| DeriveError::InvalidHex(alloc::format!("public_key: {e}")))
78 }
79}
80
81/// Unified derivation trait implemented by all chain derivers.
82///
83/// Provides a consistent API for deriving accounts regardless of the
84/// underlying chain. Each chain crate (`kobe-evm`, `kobe-btc`, etc.)
85/// implements this trait on its `Deriver` type.
86///
87/// Batch derivation is provided by the blanket [`DeriveExt`] trait.
88///
89/// # Example
90///
91/// ```no_run
92/// use kobe_primitives::{Derive, DeriveExt, DerivedAccount};
93///
94/// fn derive_first_account<D: Derive>(d: &D) -> DerivedAccount {
95/// d.derive(0).unwrap()
96/// }
97/// ```
98pub trait Derive {
99 /// The error type returned by derivation operations.
100 type Error: core::fmt::Debug + core::fmt::Display + From<DeriveError>;
101
102 /// Derive an account at the given index using the chain's default path.
103 ///
104 /// # Errors
105 ///
106 /// Returns an error if key derivation or address encoding fails.
107 fn derive(&self, index: u32) -> Result<DerivedAccount, Self::Error>;
108
109 /// Derive an account at a custom path string.
110 ///
111 /// # Errors
112 ///
113 /// Returns an error if the path is invalid or derivation fails.
114 fn derive_path(&self, path: &str) -> Result<DerivedAccount, Self::Error>;
115}
116
117/// Extension trait providing batch derivation for all [`Derive`] implementors.
118///
119/// This trait is automatically implemented for any type implementing `Derive`.
120pub trait DeriveExt: Derive {
121 /// Derive `count` accounts starting at index `start`.
122 ///
123 /// # Errors
124 ///
125 /// Returns [`DeriveError::IndexOverflow`] if `start + count` overflows `u32`.
126 fn derive_many(&self, start: u32, count: u32) -> Result<Vec<DerivedAccount>, Self::Error> {
127 let end = start.checked_add(count).ok_or(DeriveError::IndexOverflow)?;
128 (start..end).map(|i| self.derive(i)).collect()
129 }
130}
131
132impl<T: Derive> DeriveExt for T {}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 fn sample_account() -> DerivedAccount {
139 DerivedAccount::new(
140 String::from("m/44'/60'/0'/0/0"),
141 Zeroizing::new(String::from(
142 "1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727",
143 )),
144 String::from("0237b0bb7a8288d38ed49a524b5dc98cff3eb5ca824c9f9dc0dfdb3d9cd600f299"),
145 String::from("0x9858EfFD232B4033E47d90003D41EC34EcaEda94"),
146 )
147 }
148
149 #[test]
150 fn private_key_bytes_roundtrip() {
151 let acct = sample_account();
152 let bytes = acct.private_key_bytes().unwrap();
153 assert_eq!(bytes.len(), 32);
154 assert_eq!(hex::encode(*bytes), acct.private_key.as_str());
155 }
156
157 #[test]
158 fn public_key_bytes_roundtrip() {
159 let acct = sample_account();
160 let bytes = acct.public_key_bytes().unwrap();
161 assert_eq!(bytes.len(), 33);
162 assert_eq!(hex::encode(&bytes), acct.public_key);
163 }
164
165 #[test]
166 fn private_key_bytes_rejects_short_hex() {
167 let bad = DerivedAccount::new(
168 String::from("m/0"),
169 Zeroizing::new(String::from("deadbeef")),
170 String::new(),
171 String::new(),
172 );
173 assert!(matches!(
174 bad.private_key_bytes(),
175 Err(DeriveError::InvalidHex(_))
176 ));
177 }
178
179 #[test]
180 fn public_key_bytes_rejects_non_hex() {
181 let bad = DerivedAccount::new(
182 String::from("m/0"),
183 Zeroizing::new(String::new()),
184 String::from("not-hex!"),
185 String::new(),
186 );
187 assert!(matches!(
188 bad.public_key_bytes(),
189 Err(DeriveError::InvalidHex(_))
190 ));
191 }
192}