kobe_primitives/derive.rs
1//! Unified derivation trait and account types.
2//!
3//! This module defines the three cornerstone abstractions shared by every
4//! chain crate in the workspace:
5//!
6//! - [`DerivedPublicKey`] — a strongly typed sum of every public-key shape
7//! produced by the HD pipeline. Replaces an opaque `Vec<u8>` with a
8//! length-safe, algorithm-tagged enum so cross-chain code can pattern
9//! match instead of inspecting byte lengths.
10//! - [`DerivedAccount`] — the uniform view of a derived account (path,
11//! private key, public key, address) held by every chain.
12//! - [`Derive`] / [`DeriveExt`] — the derivation traits. [`Derive`] uses an
13//! associated [`Account`](Derive::Account) type so chain-specific
14//! newtypes (`BtcAccount`, `SvmAccount`, `NostrAccount`, …) are returned
15//! *without* erasure, while the [`AsRef<DerivedAccount>`] bound keeps a
16//! unified read view available for generic code.
17
18use alloc::string::String;
19use alloc::vec::Vec;
20
21use zeroize::Zeroizing;
22
23use crate::DeriveError;
24
25/// Strongly typed public key emitted by an HD derivation.
26///
27/// Each variant fixes its length and cryptographic algorithm at the type
28/// level, so consumers can branch on [`kind`](Self::kind) (or pattern
29/// match) instead of inspecting a raw byte slice.
30///
31/// # Chain mapping
32///
33/// | Chain(s) | Variant | Length |
34/// | --- | --- | --- |
35/// | `kobe-btc`, `kobe-cosmos`, `kobe-spark`, `kobe-xrpl` | [`Secp256k1Compressed`](Self::Secp256k1Compressed) | 33 B |
36/// | `kobe-evm`, `kobe-fil`, `kobe-tron` | [`Secp256k1Uncompressed`](Self::Secp256k1Uncompressed) | 65 B |
37/// | `kobe-svm`, `kobe-sui`, `kobe-aptos`, `kobe-ton` | [`Ed25519`](Self::Ed25519) | 32 B |
38/// | `kobe-nostr` | [`Secp256k1XOnly`](Self::Secp256k1XOnly) | 32 B |
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40#[non_exhaustive]
41pub enum DerivedPublicKey {
42 /// secp256k1 compressed SEC1 encoding (`0x02`/`0x03` prefix + 32-byte x).
43 Secp256k1Compressed([u8; 33]),
44 /// secp256k1 uncompressed SEC1 encoding (`0x04` prefix + 32-byte x + 32-byte y).
45 Secp256k1Uncompressed([u8; 65]),
46 /// Ed25519 32-byte public key (RFC 8032 §5.1.5).
47 Ed25519([u8; 32]),
48 /// BIP-340 x-only secp256k1 public key (32-byte x, parity dropped).
49 Secp256k1XOnly([u8; 32]),
50}
51
52impl DerivedPublicKey {
53 /// Borrow the raw bytes regardless of variant.
54 #[inline]
55 #[must_use]
56 pub const fn as_bytes(&self) -> &[u8] {
57 match self {
58 Self::Secp256k1Compressed(b) => b,
59 Self::Secp256k1Uncompressed(b) => b,
60 Self::Ed25519(b) | Self::Secp256k1XOnly(b) => b,
61 }
62 }
63
64 /// Length of the key in bytes.
65 ///
66 /// The method is named `byte_len` rather than `len` because a key is
67 /// not a collection: the "length" is a constant per variant and has no
68 /// emptiness invariant to pair with.
69 #[inline]
70 #[must_use]
71 pub const fn byte_len(&self) -> usize {
72 match self {
73 Self::Secp256k1Compressed(_) => 33,
74 Self::Secp256k1Uncompressed(_) => 65,
75 Self::Ed25519(_) | Self::Secp256k1XOnly(_) => 32,
76 }
77 }
78
79 /// Lowercase hex encoding of the raw key bytes.
80 #[inline]
81 #[must_use]
82 pub fn to_hex(&self) -> String {
83 hex::encode(self.as_bytes())
84 }
85
86 /// Cryptographic algorithm / encoding tag.
87 #[inline]
88 #[must_use]
89 pub const fn kind(&self) -> PublicKeyKind {
90 match self {
91 Self::Secp256k1Compressed(_) => PublicKeyKind::Secp256k1Compressed,
92 Self::Secp256k1Uncompressed(_) => PublicKeyKind::Secp256k1Uncompressed,
93 Self::Ed25519(_) => PublicKeyKind::Ed25519,
94 Self::Secp256k1XOnly(_) => PublicKeyKind::Secp256k1XOnly,
95 }
96 }
97
98 /// Try to build [`Secp256k1Compressed`](Self::Secp256k1Compressed) from a byte slice.
99 ///
100 /// # Errors
101 ///
102 /// Returns [`DeriveError::Crypto`] if the slice is not exactly 33 bytes long.
103 pub fn compressed(bytes: &[u8]) -> Result<Self, DeriveError> {
104 <[u8; 33]>::try_from(bytes)
105 .map(Self::Secp256k1Compressed)
106 .map_err(|_| {
107 DeriveError::Crypto(alloc::format!(
108 "compressed secp256k1 public key requires 33 bytes, got {}",
109 bytes.len()
110 ))
111 })
112 }
113
114 /// Try to build [`Secp256k1Uncompressed`](Self::Secp256k1Uncompressed) from a byte slice.
115 ///
116 /// # Errors
117 ///
118 /// Returns [`DeriveError::Crypto`] if the slice is not exactly 65 bytes long.
119 pub fn uncompressed(bytes: &[u8]) -> Result<Self, DeriveError> {
120 <[u8; 65]>::try_from(bytes)
121 .map(Self::Secp256k1Uncompressed)
122 .map_err(|_| {
123 DeriveError::Crypto(alloc::format!(
124 "uncompressed secp256k1 public key requires 65 bytes, got {}",
125 bytes.len()
126 ))
127 })
128 }
129}
130
131impl AsRef<[u8]> for DerivedPublicKey {
132 #[inline]
133 fn as_ref(&self) -> &[u8] {
134 self.as_bytes()
135 }
136}
137
138/// Tag describing [`DerivedPublicKey`]'s variant without carrying the bytes.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
140#[non_exhaustive]
141pub enum PublicKeyKind {
142 /// secp256k1 compressed SEC1.
143 Secp256k1Compressed,
144 /// secp256k1 uncompressed SEC1.
145 Secp256k1Uncompressed,
146 /// Ed25519 (RFC 8032).
147 Ed25519,
148 /// BIP-340 x-only secp256k1.
149 Secp256k1XOnly,
150}
151
152impl PublicKeyKind {
153 /// Length in bytes of any key tagged with this kind.
154 #[inline]
155 #[must_use]
156 pub const fn byte_len(self) -> usize {
157 match self {
158 Self::Secp256k1Compressed => 33,
159 Self::Secp256k1Uncompressed => 65,
160 Self::Ed25519 | Self::Secp256k1XOnly => 32,
161 }
162 }
163
164 /// Human-readable name (stable identifier for CLI / JSON output).
165 #[inline]
166 #[must_use]
167 pub const fn as_str(self) -> &'static str {
168 match self {
169 Self::Secp256k1Compressed => "secp256k1-compressed",
170 Self::Secp256k1Uncompressed => "secp256k1-uncompressed",
171 Self::Ed25519 => "ed25519",
172 Self::Secp256k1XOnly => "secp256k1-xonly",
173 }
174 }
175}
176
177impl core::fmt::Display for PublicKeyKind {
178 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
179 f.write_str(self.as_str())
180 }
181}
182
183/// A derived HD account — unified across all chains.
184///
185/// Holds the derivation path, a 32-byte private key (always zeroized on
186/// drop), a typed [`DerivedPublicKey`], and the on-chain address string.
187///
188/// Fields are private; use the accessor methods to read them. Hex-encoded
189/// views ([`private_key_hex`](Self::private_key_hex),
190/// [`public_key_hex`](Self::public_key_hex)) are computed on demand.
191///
192/// Chain crates that need to expose chain-specific fields (e.g. BTC WIF,
193/// Solana keypair, Nostr `nsec`) wrap `DerivedAccount` in a newtype and
194/// implement [`AsRef<DerivedAccount>`] + `Deref<Target = DerivedAccount>`
195/// on it. This guarantees generic code can always obtain the unified view
196/// without erasing chain-specific information.
197#[derive(Debug, Clone)]
198#[non_exhaustive]
199pub struct DerivedAccount {
200 path: String,
201 private_key: Zeroizing<[u8; 32]>,
202 public_key: DerivedPublicKey,
203 address: String,
204}
205
206impl DerivedAccount {
207 /// Construct a derived account from its components.
208 ///
209 /// Chain crates call this after completing their derivation pipeline.
210 #[inline]
211 #[must_use]
212 pub const fn new(
213 path: String,
214 private_key: Zeroizing<[u8; 32]>,
215 public_key: DerivedPublicKey,
216 address: String,
217 ) -> Self {
218 Self {
219 path,
220 private_key,
221 public_key,
222 address,
223 }
224 }
225
226 /// BIP-32 / SLIP-10 derivation path (e.g. `m/44'/60'/0'/0/0`).
227 #[inline]
228 #[must_use]
229 pub fn path(&self) -> &str {
230 &self.path
231 }
232
233 /// Raw 32-byte private key (zeroized on drop).
234 #[inline]
235 #[must_use]
236 pub const fn private_key_bytes(&self) -> &Zeroizing<[u8; 32]> {
237 &self.private_key
238 }
239
240 /// Lowercase hex-encoded private key (64 chars, zeroized on drop).
241 #[inline]
242 #[must_use]
243 pub fn private_key_hex(&self) -> Zeroizing<String> {
244 Zeroizing::new(hex::encode(*self.private_key))
245 }
246
247 /// Typed public key, carrying algorithm + length information at the type level.
248 #[inline]
249 #[must_use]
250 pub const fn public_key(&self) -> &DerivedPublicKey {
251 &self.public_key
252 }
253
254 /// Public key bytes, chain-specific layout.
255 ///
256 /// For pattern-matching on the algorithm, use
257 /// [`public_key`](Self::public_key) instead.
258 #[inline]
259 #[must_use]
260 pub const fn public_key_bytes(&self) -> &[u8] {
261 self.public_key.as_bytes()
262 }
263
264 /// Lowercase hex-encoded public key.
265 #[inline]
266 #[must_use]
267 pub fn public_key_hex(&self) -> String {
268 self.public_key.to_hex()
269 }
270
271 /// On-chain address in the chain's native format.
272 #[inline]
273 #[must_use]
274 pub fn address(&self) -> &str {
275 &self.address
276 }
277}
278
279impl AsRef<Self> for DerivedAccount {
280 #[inline]
281 fn as_ref(&self) -> &Self {
282 self
283 }
284}
285
286/// Derive a range of accounts by repeatedly invoking a derivation closure.
287///
288/// Generic building block for every chain's batch-derivation entry point:
289/// validates `start + count` against `u32` overflow and collects the
290/// results into a `Vec<T>`.
291///
292/// # Errors
293///
294/// Returns [`DeriveError::Input`] (wrapped via `E: From<DeriveError>`) if
295/// `start + count` overflows `u32`, or propagates any error produced by
296/// `f`.
297///
298/// # Example
299///
300/// ```no_run
301/// use kobe_primitives::{DerivedAccount, DeriveError, derive_range};
302///
303/// fn batch(count: u32) -> Result<Vec<DerivedAccount>, DeriveError> {
304/// derive_range(0, count, |_i| todo!("derive one"))
305/// }
306/// ```
307pub fn derive_range<T, E, F>(start: u32, count: u32, f: F) -> Result<Vec<T>, E>
308where
309 F: FnMut(u32) -> Result<T, E>,
310 E: From<DeriveError>,
311{
312 let end = start.checked_add(count).ok_or_else(|| {
313 E::from(DeriveError::Input(String::from(
314 "derive_many: start + count overflows u32",
315 )))
316 })?;
317 (start..end).map(f).collect()
318}
319
320/// Unified derivation trait implemented by every chain deriver.
321///
322/// Each chain implements this trait on its `Deriver` type and declares the
323/// account newtype it returns via the associated [`Account`](Self::Account)
324/// type. The [`AsRef<DerivedAccount>`] bound keeps the unified read view
325/// available to generic callers, without erasing chain-specific metadata
326/// (BTC WIF, Solana keypair, Nostr `nsec`, …).
327///
328/// Batch derivation is provided by the blanket [`DeriveExt`] trait.
329///
330/// # Example
331///
332/// ```no_run
333/// use kobe_primitives::{Derive, DerivedAccount};
334///
335/// fn first_address<D: Derive>(d: &D) -> String {
336/// // `as_ref` yields the unified view regardless of the chain newtype.
337/// let account = d.derive(0).unwrap();
338/// let view: &DerivedAccount = account.as_ref();
339/// view.address().to_owned()
340/// }
341/// ```
342pub trait Derive {
343 /// The (possibly newtype) account returned by this deriver.
344 ///
345 /// Chains without chain-specific metadata set `Account = DerivedAccount`
346 /// directly; chains with extra fields (BTC, SVM, Nostr) return their
347 /// own `<Chain>Account` wrapper.
348 type Account: AsRef<DerivedAccount>;
349
350 /// The error type returned by derivation operations.
351 type Error: core::fmt::Debug + core::fmt::Display + From<DeriveError>;
352
353 /// Derive an account at the given index using the chain's default path.
354 ///
355 /// # Errors
356 ///
357 /// Returns an error if key derivation or address encoding fails.
358 fn derive(&self, index: u32) -> Result<Self::Account, Self::Error>;
359
360 /// Derive an account at a custom path string.
361 ///
362 /// # Errors
363 ///
364 /// Returns an error if the path is invalid or derivation fails.
365 fn derive_path(&self, path: &str) -> Result<Self::Account, Self::Error>;
366}
367
368/// Extension trait providing batch derivation for every [`Derive`] implementor.
369///
370/// Blanket-implemented for any `T: Derive`, so importing the trait is the
371/// only requirement:
372///
373/// ```no_run
374/// use kobe_primitives::{Derive, DeriveExt};
375/// # struct D;
376/// # impl Derive for D {
377/// # type Account = kobe_primitives::DerivedAccount;
378/// # type Error = kobe_primitives::DeriveError;
379/// # fn derive(&self, _: u32) -> Result<Self::Account, Self::Error> { unimplemented!() }
380/// # fn derive_path(&self, _: &str) -> Result<Self::Account, Self::Error> { unimplemented!() }
381/// # }
382/// # let d = D;
383/// let accounts = d.derive_many(0, 5).unwrap();
384/// ```
385pub trait DeriveExt: Derive {
386 /// Derive `count` accounts starting at index `start`.
387 ///
388 /// # Errors
389 ///
390 /// Returns [`DeriveError::Input`] if `start + count` overflows `u32`,
391 /// or propagates any derivation error.
392 #[inline]
393 fn derive_many(&self, start: u32, count: u32) -> Result<Vec<Self::Account>, Self::Error> {
394 derive_range(start, count, |i| self.derive(i))
395 }
396}
397
398impl<T: Derive> DeriveExt for T {}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 fn sample_account() -> DerivedAccount {
405 let mut sk = Zeroizing::new([0u8; 32]);
406 hex::decode_to_slice(
407 "1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727",
408 sk.as_mut_slice(),
409 )
410 .unwrap();
411 let mut pk = [0u8; 33];
412 hex::decode_to_slice(
413 "0237b0bb7a8288d38ed49a524b5dc98cff3eb5ca824c9f9dc0dfdb3d9cd600f299",
414 &mut pk,
415 )
416 .unwrap();
417 DerivedAccount::new(
418 String::from("m/44'/60'/0'/0/0"),
419 sk,
420 DerivedPublicKey::Secp256k1Compressed(pk),
421 String::from("0x9858EfFD232B4033E47d90003D41EC34EcaEda94"),
422 )
423 }
424
425 #[test]
426 fn accessors_expose_all_fields() {
427 let acct = sample_account();
428 assert_eq!(acct.path(), "m/44'/60'/0'/0/0");
429 assert_eq!(acct.private_key_bytes().len(), 32);
430 assert_eq!(
431 acct.private_key_hex().as_str(),
432 "1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727"
433 );
434 assert_eq!(acct.public_key().kind(), PublicKeyKind::Secp256k1Compressed);
435 assert_eq!(acct.public_key().byte_len(), 33);
436 assert_eq!(acct.public_key_bytes().len(), 33);
437 assert_eq!(
438 acct.public_key_hex(),
439 "0237b0bb7a8288d38ed49a524b5dc98cff3eb5ca824c9f9dc0dfdb3d9cd600f299"
440 );
441 assert_eq!(acct.address(), "0x9858EfFD232B4033E47d90003D41EC34EcaEda94");
442 }
443
444 #[test]
445 fn private_key_hex_is_reversible() {
446 let acct = sample_account();
447 let hex = acct.private_key_hex();
448 let mut decoded = [0u8; 32];
449 hex::decode_to_slice(hex.as_str(), &mut decoded).unwrap();
450 assert_eq!(&decoded, acct.private_key_bytes().as_ref());
451 }
452
453 #[test]
454 fn derived_public_key_compressed_constructor_validates_length() {
455 let ok = DerivedPublicKey::compressed(&[0x02; 33]).unwrap();
456 assert_eq!(ok.kind(), PublicKeyKind::Secp256k1Compressed);
457 assert!(DerivedPublicKey::compressed(&[0u8; 32]).is_err());
458 assert!(DerivedPublicKey::compressed(&[0u8; 34]).is_err());
459 }
460
461 #[test]
462 fn derived_public_key_uncompressed_constructor_validates_length() {
463 let ok = DerivedPublicKey::uncompressed(&[0x04; 65]).unwrap();
464 assert_eq!(ok.kind(), PublicKeyKind::Secp256k1Uncompressed);
465 assert!(DerivedPublicKey::uncompressed(&[0u8; 64]).is_err());
466 assert!(DerivedPublicKey::uncompressed(&[0u8; 66]).is_err());
467 }
468
469 #[test]
470 fn public_key_kind_length_round_trips() {
471 let ed = DerivedPublicKey::Ed25519([0u8; 32]);
472 assert_eq!(ed.byte_len(), PublicKeyKind::Ed25519.byte_len());
473 let xonly = DerivedPublicKey::Secp256k1XOnly([0u8; 32]);
474 assert_eq!(xonly.byte_len(), PublicKeyKind::Secp256k1XOnly.byte_len());
475 }
476
477 #[test]
478 fn derived_account_as_ref_is_identity() {
479 let acct = sample_account();
480 let borrowed: &DerivedAccount = acct.as_ref();
481 assert!(core::ptr::eq(borrowed, &raw const acct));
482 }
483}