1use alloc::string::String;
4use core::ops::Deref;
5
6use kobe_primitives::{
7 DerivationStyle as _, Derive, DeriveError, DerivedAccount, DerivedPublicKey, Wallet,
8};
9
10use crate::address::{
11 ED25519_TAG, SECP256K1_TAG, account_hash_ed25519, account_hash_secp256k1, format_account_hash,
12 tagged_public_key_hex,
13};
14use crate::key_algo::KeyAlgo;
15
16#[derive(Clone)]
26pub struct CasperAccount {
27 inner: DerivedAccount,
28 algo: KeyAlgo,
29 tagged_public_key_hex: String,
31}
32
33impl core::fmt::Debug for CasperAccount {
34 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
35 f.debug_struct("CasperAccount")
36 .field("inner", &self.inner)
37 .field("algo", &self.algo)
38 .field("tagged_public_key_hex", &self.tagged_public_key_hex)
39 .finish()
40 }
41}
42
43impl CasperAccount {
44 #[inline]
46 #[must_use]
47 pub const fn algo(&self) -> KeyAlgo {
48 self.algo
49 }
50
51 #[inline]
56 #[must_use]
57 pub fn tagged_public_key_hex(&self) -> &str {
58 &self.tagged_public_key_hex
59 }
60
61 #[inline]
64 #[must_use]
65 pub fn account_hash(&self) -> &str {
66 self.inner.address()
67 }
68
69 #[inline]
71 #[must_use]
72 pub const fn as_derived_account(&self) -> &DerivedAccount {
73 &self.inner
74 }
75
76 #[inline]
78 #[must_use]
79 pub fn into_derived_account(self) -> DerivedAccount {
80 self.inner
81 }
82}
83
84impl Deref for CasperAccount {
85 type Target = DerivedAccount;
86
87 #[inline]
88 fn deref(&self) -> &Self::Target {
89 &self.inner
90 }
91}
92
93impl AsRef<DerivedAccount> for CasperAccount {
94 #[inline]
95 fn as_ref(&self) -> &DerivedAccount {
96 &self.inner
97 }
98}
99
100impl From<CasperAccount> for DerivedAccount {
101 #[inline]
102 fn from(account: CasperAccount) -> Self {
103 account.inner
104 }
105}
106
107#[derive(Debug)]
112pub struct Deriver<'a> {
113 wallet: &'a Wallet,
114 algo: KeyAlgo,
115}
116
117impl<'a> Deriver<'a> {
118 #[inline]
120 #[must_use]
121 pub const fn new(wallet: &'a Wallet) -> Self {
122 Self {
123 wallet,
124 algo: KeyAlgo::Secp256k1,
125 }
126 }
127
128 #[inline]
131 #[must_use]
132 pub const fn with_algo(wallet: &'a Wallet, algo: KeyAlgo) -> Self {
133 Self { wallet, algo }
134 }
135
136 #[inline]
138 #[must_use]
139 pub const fn algo(&self) -> KeyAlgo {
140 self.algo
141 }
142
143 #[inline]
149 pub fn derive(&self, index: u32) -> Result<CasperAccount, DeriveError> {
150 self.derive_with(self.algo, index)
151 }
152
153 pub fn derive_with(&self, algo: KeyAlgo, index: u32) -> Result<CasperAccount, DeriveError> {
159 self.derive_at_with(&algo.path(index), algo)
160 }
161
162 #[inline]
172 pub fn derive_at(&self, path: &str) -> Result<CasperAccount, DeriveError> {
173 self.derive_at_with(path, self.algo)
174 }
175
176 pub fn derive_at_with(&self, path: &str, algo: KeyAlgo) -> Result<CasperAccount, DeriveError> {
185 match algo {
186 KeyAlgo::Secp256k1 => self.derive_secp(path),
187 KeyAlgo::Ed25519 => self.derive_ed25519(path),
188 }
189 }
190
191 fn derive_secp(&self, path: &str) -> Result<CasperAccount, DeriveError> {
192 let key = self.wallet.derive_secp256k1(path)?;
193 let compressed = key.compressed_pubkey();
194 let digest = account_hash_secp256k1(&compressed)?;
195 let address = format_account_hash(&digest);
196 let tagged = tagged_public_key_hex(SECP256K1_TAG, &compressed);
197 let sk = key.private_key_bytes();
198
199 let inner = DerivedAccount::new(
200 String::from(path),
201 sk,
202 DerivedPublicKey::Secp256k1Compressed(compressed),
203 address,
204 );
205
206 Ok(CasperAccount {
207 inner,
208 algo: KeyAlgo::Secp256k1,
209 tagged_public_key_hex: tagged,
210 })
211 }
212
213 fn derive_ed25519(&self, path: &str) -> Result<CasperAccount, DeriveError> {
214 let derived = self.wallet.derive_ed25519(path)?;
215 let pubkey_bytes = derived.public_key_bytes();
216 let digest = account_hash_ed25519(&pubkey_bytes)?;
217 let address = format_account_hash(&digest);
218 let tagged = tagged_public_key_hex(ED25519_TAG, &pubkey_bytes);
219 let sk_bytes = derived.private_key_bytes();
220
221 let inner = DerivedAccount::new(
222 String::from(path),
223 sk_bytes,
224 DerivedPublicKey::Ed25519(pubkey_bytes),
225 address,
226 );
227
228 Ok(CasperAccount {
229 inner,
230 algo: KeyAlgo::Ed25519,
231 tagged_public_key_hex: tagged,
232 })
233 }
234}
235
236impl Derive for Deriver<'_> {
237 type Account = CasperAccount;
238 type Error = DeriveError;
239
240 fn derive(&self, index: u32) -> Result<CasperAccount, DeriveError> {
241 Deriver::derive(self, index)
242 }
243
244 fn derive_path(&self, path: &str) -> Result<CasperAccount, DeriveError> {
245 self.derive_at(path)
246 }
247}
248
249#[cfg(test)]
250#[allow(
251 clippy::unwrap_used,
252 clippy::expect_used,
253 clippy::indexing_slicing,
254 clippy::panic,
255 reason = "unit tests"
256)]
257mod tests {
258 use alloc::format;
259 use alloc::vec::Vec;
260
261 use kobe_primitives::DeriveExt;
262
263 use super::*;
264 use crate::address::{account_hash_ed25519, account_hash_secp256k1, format_account_hash};
265
266 const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
268
269 const SECP0_PRIV: &str = "9c72144893c3ca5fa7299e65a7d7d6c41ab6a7add5f9860618324854d3c369d1";
276 const SECP0_ADDR: &str =
277 "account-hash-e699fcd4904aa6617b2930c6d8995a6f301708b6a64621820a5896d92e2457b3";
278 const SECP0_TAGGED: &str =
279 "020357f9e27d8125932c5e6fd52babb1a114bc89363f2f56c7860bb594f74523342b";
280
281 const ED0_PRIV: &str = "619386127005778f66a68fa91518c0841f59495790bb796fc781ecdd54fe329a";
287 const ED0_ADDR: &str =
288 "account-hash-356106f683840956a5bff75d011b236068ceccdf09d5c1a6a748c9355b635e08";
289 const ED0_TAGGED: &str = "016a1585d8197fc14b1d8cc05d5351e5ba04810d466158a050494c799b776ff819";
290
291 fn test_wallet() -> Wallet {
292 Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap()
293 }
294
295 #[test]
297 fn default_algo_is_secp_path() {
298 let a = Deriver::new(&test_wallet()).derive(0).unwrap();
299 assert_eq!(a.algo(), KeyAlgo::Secp256k1);
300 assert_eq!(a.path(), "m/44'/506'/0'/0/0");
301 assert!(a.address().starts_with("account-hash-"));
302 assert_eq!(a.address().len(), "account-hash-".len() + 64);
303 assert!(a.tagged_public_key_hex().starts_with("02"));
304 assert_eq!(a.tagged_public_key_hex().len(), 2 + 66); assert_ne!(a.public_key_hex(), a.tagged_public_key_hex());
307 assert_eq!(a.public_key_hex().len(), 66);
308 }
309
310 #[test]
312 fn account_hash_matches_pubkey_encoding_secp() {
313 let a = Deriver::new(&test_wallet()).derive(0).unwrap();
314 let pk = match a.public_key() {
315 DerivedPublicKey::Secp256k1Compressed(b) => b,
316 other => panic!("expected compressed secp, got {other:?}"),
317 };
318 let digest = account_hash_secp256k1(pk).unwrap();
319 assert_eq!(a.address(), format_account_hash(&digest));
320 assert_eq!(a.tagged_public_key_hex(), format!("02{}", hex::encode(pk)));
321 }
322
323 #[test]
324 fn account_hash_matches_pubkey_encoding_ed25519() {
325 let a = Deriver::with_algo(&test_wallet(), KeyAlgo::Ed25519)
326 .derive(0)
327 .unwrap();
328 assert_eq!(a.path(), "m/44'/506'/0'/0'/0'");
329 let pk = match a.public_key() {
330 DerivedPublicKey::Ed25519(b) => b,
331 other => panic!("expected ed25519, got {other:?}"),
332 };
333 let digest = account_hash_ed25519(pk).unwrap();
334 assert_eq!(a.address(), format_account_hash(&digest));
335 assert!(a.tagged_public_key_hex().starts_with("01"));
336 assert_eq!(a.tagged_public_key_hex().len(), 2 + 64);
337 }
338
339 #[test]
340 fn kat_secp_abandon_index0() {
341 let a = Deriver::new(&test_wallet()).derive(0).unwrap();
342 assert_eq!(a.path(), "m/44'/506'/0'/0/0");
343 let sk = a.private_key_hex();
344 assert_eq!(sk.as_str(), SECP0_PRIV);
345 assert_eq!(a.address(), SECP0_ADDR);
346 assert_eq!(a.tagged_public_key_hex(), SECP0_TAGGED);
347 }
348
349 #[test]
350 fn kat_ed25519_abandon_index0() {
351 let a = Deriver::with_algo(&test_wallet(), KeyAlgo::Ed25519)
352 .derive(0)
353 .unwrap();
354 assert_eq!(a.path(), "m/44'/506'/0'/0'/0'");
355 let sk = a.private_key_hex();
356 assert_eq!(sk.as_str(), ED0_PRIV);
357 assert_eq!(a.address(), ED0_ADDR);
358 assert_eq!(a.tagged_public_key_hex(), ED0_TAGGED);
359 }
360
361 #[test]
362 fn debug_redacts_private_key() {
363 let a = Deriver::new(&test_wallet()).derive(0).unwrap();
364 let dbg = format!("{a:?}");
365 assert!(
366 dbg.contains("[REDACTED]"),
367 "Debug must redact private key: {dbg}"
368 );
369 assert!(
370 !dbg.contains(SECP0_PRIV),
371 "Debug must not leak private key hex: {dbg}"
372 );
373 assert!(dbg.contains("tagged_public_key_hex"));
374 }
375
376 #[test]
377 fn kat_secp_abandon_index1_differs() {
378 let w = test_wallet();
379 let d = Deriver::new(&w);
380 let a0 = d.derive(0).unwrap();
381 let a1 = d.derive(1).unwrap();
382 assert_ne!(a0.address(), a1.address());
383 assert_eq!(a1.path(), "m/44'/506'/0'/0/1");
384 }
385
386 #[test]
387 fn derive_many_matches_individual() {
388 let w = test_wallet();
389 let d = Deriver::new(&w);
390 let batch = d.derive_many(0, 3).unwrap();
391 let single: Vec<_> = (0..3).map(|i| d.derive(i).unwrap()).collect();
392 for (b, s) in batch.iter().zip(single.iter()) {
393 assert_eq!(b.address(), s.address());
394 assert_eq!(b.path(), s.path());
395 assert_eq!(b.tagged_public_key_hex(), s.tagged_public_key_hex());
396 }
397 }
398
399 #[test]
400 fn passphrase_changes_derivation() {
401 let w = Wallet::from_mnemonic(TEST_MNEMONIC, Some("TREZOR")).unwrap();
402 assert_ne!(
403 Deriver::new(&test_wallet()).derive(0).unwrap().address(),
404 Deriver::new(&w).derive(0).unwrap().address(),
405 );
406 }
407
408 #[test]
409 fn ed_and_secp_addresses_differ() {
410 let w = test_wallet();
411 let secp = Deriver::new(&w).derive(0).unwrap();
412 let ed = Deriver::with_algo(&w, KeyAlgo::Ed25519).derive(0).unwrap();
413 assert_ne!(secp.address(), ed.address());
414 }
415}