Skip to main content

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}