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`, `kobe-arweave`, `kobe-casper` (secp) | [`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`, `kobe-casper` (ed25519) | [`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(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 core::fmt::Debug for DerivedAccount {
207    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
208        // Never print private key material — Zeroizing's Debug is not redacting.
209        f.debug_struct("DerivedAccount")
210            .field("path", &self.path)
211            .field("private_key", &"[REDACTED]")
212            .field("public_key", &self.public_key)
213            .field("address", &self.address)
214            .finish()
215    }
216}
217
218impl DerivedAccount {
219    /// Construct a derived account from its components.
220    ///
221    /// Chain crates call this after completing their derivation pipeline.
222    #[inline]
223    #[must_use]
224    pub const fn new(
225        path: String,
226        private_key: Zeroizing<[u8; 32]>,
227        public_key: DerivedPublicKey,
228        address: String,
229    ) -> Self {
230        Self {
231            path,
232            private_key,
233            public_key,
234            address,
235        }
236    }
237
238    /// BIP-32 / SLIP-10 derivation path (e.g. `m/44'/60'/0'/0/0`).
239    #[inline]
240    #[must_use]
241    pub fn path(&self) -> &str {
242        &self.path
243    }
244
245    /// Raw 32-byte private key (zeroized on drop).
246    #[inline]
247    #[must_use]
248    pub const fn private_key_bytes(&self) -> &Zeroizing<[u8; 32]> {
249        &self.private_key
250    }
251
252    /// Lowercase hex-encoded private key (64 chars, zeroized on drop).
253    #[inline]
254    #[must_use]
255    pub fn private_key_hex(&self) -> Zeroizing<String> {
256        Zeroizing::new(hex::encode(*self.private_key))
257    }
258
259    /// Typed public key, carrying algorithm + length information at the type level.
260    #[inline]
261    #[must_use]
262    pub const fn public_key(&self) -> &DerivedPublicKey {
263        &self.public_key
264    }
265
266    /// Public key bytes, chain-specific layout.
267    ///
268    /// For pattern-matching on the algorithm, use
269    /// [`public_key`](Self::public_key) instead.
270    #[inline]
271    #[must_use]
272    pub const fn public_key_bytes(&self) -> &[u8] {
273        self.public_key.as_bytes()
274    }
275
276    /// Lowercase hex-encoded public key.
277    #[inline]
278    #[must_use]
279    pub fn public_key_hex(&self) -> String {
280        self.public_key.to_hex()
281    }
282
283    /// On-chain address in the chain's native format.
284    #[inline]
285    #[must_use]
286    pub fn address(&self) -> &str {
287        &self.address
288    }
289}
290
291impl AsRef<Self> for DerivedAccount {
292    #[inline]
293    fn as_ref(&self) -> &Self {
294        self
295    }
296}
297
298/// Derive a range of accounts by repeatedly invoking a derivation closure.
299///
300/// Generic building block for every chain's batch-derivation entry point:
301/// validates `start + count` against `u32` overflow and collects the
302/// results into a `Vec<T>`.
303///
304/// # Errors
305///
306/// Returns [`DeriveError::Input`] (wrapped via `E: From<DeriveError>`) if
307/// `start + count` overflows `u32`, or propagates any error produced by
308/// `f`.
309///
310/// # Example
311///
312/// ```no_run
313/// use kobe_primitives::{DerivedAccount, DeriveError, derive_range};
314///
315/// fn batch(count: u32) -> Result<Vec<DerivedAccount>, DeriveError> {
316///     derive_range(0, count, |_i| todo!("derive one"))
317/// }
318/// ```
319pub fn derive_range<T, E, F>(start: u32, count: u32, f: F) -> Result<Vec<T>, E>
320where
321    F: FnMut(u32) -> Result<T, E>,
322    E: From<DeriveError>,
323{
324    let end = start.checked_add(count).ok_or_else(|| {
325        E::from(DeriveError::Input(String::from(
326            "derive_many: start + count overflows u32",
327        )))
328    })?;
329    (start..end).map(f).collect()
330}
331
332/// Unified derivation trait implemented by every chain deriver.
333///
334/// Each chain implements this trait on its `Deriver` type and declares the
335/// account newtype it returns via the associated [`Account`](Self::Account)
336/// type. The [`AsRef<DerivedAccount>`] bound keeps the unified read view
337/// available to generic callers, without erasing chain-specific metadata
338/// (BTC WIF, Solana keypair, Nostr `nsec`, …).
339///
340/// Batch derivation is provided by the blanket [`DeriveExt`] trait.
341///
342/// # Example
343///
344/// ```no_run
345/// use kobe_primitives::{Derive, DerivedAccount};
346///
347/// fn first_address<D: Derive>(d: &D) -> String {
348///     // `as_ref` yields the unified view regardless of the chain newtype.
349///     let account = d.derive(0).unwrap();
350///     let view: &DerivedAccount = account.as_ref();
351///     view.address().to_owned()
352/// }
353/// ```
354pub trait Derive {
355    /// The (possibly newtype) account returned by this deriver.
356    ///
357    /// Chains without chain-specific metadata set `Account = DerivedAccount`
358    /// directly; chains with extra fields (BTC, SVM, Nostr) return their
359    /// own `<Chain>Account` wrapper.
360    type Account: AsRef<DerivedAccount>;
361
362    /// The error type returned by derivation operations.
363    type Error: core::fmt::Debug + core::fmt::Display + From<DeriveError>;
364
365    /// Derive an account at the given index using the chain's default path.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if key derivation or address encoding fails.
370    fn derive(&self, index: u32) -> Result<Self::Account, Self::Error>;
371
372    /// Derive an account at a custom path string.
373    ///
374    /// # Errors
375    ///
376    /// Returns an error if the path is invalid or derivation fails.
377    fn derive_path(&self, path: &str) -> Result<Self::Account, Self::Error>;
378}
379
380/// Extension trait providing batch derivation for every [`Derive`] implementor.
381///
382/// Blanket-implemented for any `T: Derive`, so importing the trait is the
383/// only requirement:
384///
385/// ```no_run
386/// use kobe_primitives::{Derive, DeriveExt};
387/// # struct D;
388/// # impl Derive for D {
389/// #     type Account = kobe_primitives::DerivedAccount;
390/// #     type Error = kobe_primitives::DeriveError;
391/// #     fn derive(&self, _: u32) -> Result<Self::Account, Self::Error> { unimplemented!() }
392/// #     fn derive_path(&self, _: &str) -> Result<Self::Account, Self::Error> { unimplemented!() }
393/// # }
394/// # let d = D;
395/// let accounts = d.derive_many(0, 5).unwrap();
396/// ```
397pub trait DeriveExt: Derive {
398    /// Derive `count` accounts starting at index `start`.
399    ///
400    /// # Errors
401    ///
402    /// Returns [`DeriveError::Input`] if `start + count` overflows `u32`,
403    /// or propagates any derivation error.
404    #[inline]
405    fn derive_many(&self, start: u32, count: u32) -> Result<Vec<Self::Account>, Self::Error> {
406        derive_range(start, count, |i| self.derive(i))
407    }
408}
409
410impl<T: Derive> DeriveExt for T {}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    fn sample_account() -> DerivedAccount {
417        let mut sk = Zeroizing::new([0u8; 32]);
418        hex::decode_to_slice(
419            "1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727",
420            sk.as_mut_slice(),
421        )
422        .unwrap();
423        let mut pk = [0u8; 33];
424        hex::decode_to_slice(
425            "0237b0bb7a8288d38ed49a524b5dc98cff3eb5ca824c9f9dc0dfdb3d9cd600f299",
426            &mut pk,
427        )
428        .unwrap();
429        DerivedAccount::new(
430            String::from("m/44'/60'/0'/0/0"),
431            sk,
432            DerivedPublicKey::Secp256k1Compressed(pk),
433            String::from("0x9858EfFD232B4033E47d90003D41EC34EcaEda94"),
434        )
435    }
436
437    #[test]
438    fn accessors_expose_all_fields() {
439        let acct = sample_account();
440        assert_eq!(acct.path(), "m/44'/60'/0'/0/0");
441        assert_eq!(acct.private_key_bytes().len(), 32);
442        assert_eq!(
443            acct.private_key_hex().as_str(),
444            "1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727"
445        );
446        assert_eq!(acct.public_key().kind(), PublicKeyKind::Secp256k1Compressed);
447        assert_eq!(acct.public_key().byte_len(), 33);
448        assert_eq!(acct.public_key_bytes().len(), 33);
449        assert_eq!(
450            acct.public_key_hex(),
451            "0237b0bb7a8288d38ed49a524b5dc98cff3eb5ca824c9f9dc0dfdb3d9cd600f299"
452        );
453        assert_eq!(acct.address(), "0x9858EfFD232B4033E47d90003D41EC34EcaEda94");
454    }
455
456    #[test]
457    fn private_key_hex_is_reversible() {
458        let acct = sample_account();
459        let hex = acct.private_key_hex();
460        let mut decoded = [0u8; 32];
461        hex::decode_to_slice(hex.as_str(), &mut decoded).unwrap();
462        assert_eq!(&decoded, acct.private_key_bytes().as_ref());
463    }
464
465    #[test]
466    fn derived_public_key_compressed_constructor_validates_length() {
467        let ok = DerivedPublicKey::compressed(&[0x02; 33]).unwrap();
468        assert_eq!(ok.kind(), PublicKeyKind::Secp256k1Compressed);
469        assert!(DerivedPublicKey::compressed(&[0u8; 32]).is_err());
470        assert!(DerivedPublicKey::compressed(&[0u8; 34]).is_err());
471    }
472
473    #[test]
474    fn derived_public_key_uncompressed_constructor_validates_length() {
475        let ok = DerivedPublicKey::uncompressed(&[0x04; 65]).unwrap();
476        assert_eq!(ok.kind(), PublicKeyKind::Secp256k1Uncompressed);
477        assert!(DerivedPublicKey::uncompressed(&[0u8; 64]).is_err());
478        assert!(DerivedPublicKey::uncompressed(&[0u8; 66]).is_err());
479    }
480
481    #[test]
482    fn public_key_kind_length_round_trips() {
483        let ed = DerivedPublicKey::Ed25519([0u8; 32]);
484        assert_eq!(ed.byte_len(), PublicKeyKind::Ed25519.byte_len());
485        let xonly = DerivedPublicKey::Secp256k1XOnly([0u8; 32]);
486        assert_eq!(xonly.byte_len(), PublicKeyKind::Secp256k1XOnly.byte_len());
487    }
488
489    #[test]
490    fn derived_account_as_ref_is_identity() {
491        let acct = sample_account();
492        let borrowed: &DerivedAccount = acct.as_ref();
493        assert!(core::ptr::eq(borrowed, &raw const acct));
494    }
495
496    #[test]
497    fn debug_redacts_private_key() {
498        let acct = sample_account();
499        let dbg = alloc::format!("{acct:?}");
500        assert!(
501            dbg.contains("[REDACTED]"),
502            "expected redaction markers: {dbg}"
503        );
504        assert!(
505            !dbg.contains("1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727"),
506            "Debug must not leak private key hex: {dbg}"
507        );
508        // Public material remains visible for diagnostics.
509        assert!(dbg.contains("0x9858EfFD232B4033E47d90003D41EC34EcaEda94"));
510    }
511}