Skip to main content

chains_sdk/
mnemonic.rs

1//! **BIP-39** Mnemonic seed phrase support.
2//!
3//! Generates 12/24-word mnemonic phrases from entropy, converts them back to
4//! seeds via PBKDF2-SHA512, and integrates with the BIP-32 HD key module.
5//!
6//! # Example
7//! ```no_run
8//! use chains_sdk::mnemonic::Mnemonic;
9//! use chains_sdk::hd_key::{ExtendedPrivateKey, DerivationPath};
10//!
11//! fn main() -> Result<(), Box<dyn std::error::Error>> {
12//!     let mnemonic = Mnemonic::generate(12)?; // 12-word phrase
13//!     let seed = mnemonic.to_seed("optional passphrase");
14//!     let master = ExtendedPrivateKey::from_seed(&*seed)?;
15//!     let eth_key = master.derive_path(&DerivationPath::ethereum(0))?;
16//!     Ok(())
17//! }
18//! ```
19
20use crate::error::SignerError;
21use sha2::{Digest, Sha256};
22use zeroize::Zeroizing;
23
24/// BIP-39 English wordlist (2048 words).
25const WORDLIST: &str = include_str!("bip39_english.txt");
26
27/// A BIP-39 mnemonic phrase.
28pub struct Mnemonic {
29    /// The mnemonic words.
30    words: Zeroizing<String>,
31}
32
33impl Mnemonic {
34    /// Generate a new random mnemonic with the given word count.
35    ///
36    /// Supported counts: 12 (128-bit), 15 (160-bit), 18 (192-bit), 21 (224-bit), 24 (256-bit).
37    pub fn generate(word_count: usize) -> Result<Self, SignerError> {
38        let entropy_bits = match word_count {
39            12 => 128,
40            15 => 160,
41            18 => 192,
42            21 => 224,
43            24 => 256,
44            _ => {
45                return Err(SignerError::InvalidPrivateKey(
46                    "word count must be 12, 15, 18, 21, or 24".into(),
47                ))
48            }
49        };
50
51        let entropy_bytes = entropy_bits / 8;
52        let mut entropy = vec![0u8; entropy_bytes];
53        crate::security::secure_random(&mut entropy)?;
54
55        Self::from_entropy(&entropy)
56    }
57
58    /// Create a mnemonic from raw entropy bytes.
59    ///
60    /// Entropy length must be 16, 20, 24, 28, or 32 bytes.
61    pub fn from_entropy(entropy: &[u8]) -> Result<Self, SignerError> {
62        let ent_bits = entropy.len() * 8;
63        if ![128, 160, 192, 224, 256].contains(&ent_bits) {
64            return Err(SignerError::InvalidPrivateKey(format!(
65                "entropy must be 16-32 bytes (128-256 bits), got {} bytes",
66                entropy.len()
67            )));
68        }
69
70        let wordlist: Vec<&str> = WORDLIST.lines().collect();
71        if wordlist.len() != 2048 {
72            return Err(SignerError::InvalidPrivateKey(
73                "invalid BIP-39 wordlist".into(),
74            ));
75        }
76
77        // Compute checksum: first CS bits of SHA-256(entropy)
78        let cs_bits = ent_bits / 32;
79        let hash = Sha256::digest(entropy);
80
81        // Build the full bit string: entropy || checksum
82        let total_bits = ent_bits + cs_bits;
83        let word_count = total_bits / 11;
84
85        let mut words = Vec::with_capacity(word_count);
86        for i in 0..word_count {
87            let mut idx: u32 = 0;
88            for j in 0..11 {
89                let bit_pos = i * 11 + j;
90                let bit = if bit_pos < ent_bits {
91                    // From entropy
92                    (entropy[bit_pos / 8] >> (7 - (bit_pos % 8))) & 1
93                } else {
94                    // From checksum
95                    let cs_pos = bit_pos - ent_bits;
96                    (hash[cs_pos / 8] >> (7 - (cs_pos % 8))) & 1
97                };
98                idx = (idx << 1) | u32::from(bit);
99            }
100            words.push(wordlist[idx as usize]);
101        }
102
103        Ok(Self {
104            words: Zeroizing::new(words.join(" ")),
105        })
106    }
107
108    /// Parse a mnemonic phrase from a string.
109    ///
110    /// Validates word count and checksum.
111    pub fn from_phrase(phrase: &str) -> Result<Self, SignerError> {
112        let wordlist: Vec<&str> = WORDLIST.lines().collect();
113        if wordlist.len() != 2048 {
114            return Err(SignerError::InvalidPrivateKey(
115                "invalid BIP-39 wordlist".into(),
116            ));
117        }
118
119        let words: Vec<&str> = phrase.split_whitespace().collect();
120        let word_count = words.len();
121        if ![12, 15, 18, 21, 24].contains(&word_count) {
122            return Err(SignerError::InvalidPrivateKey(format!(
123                "invalid word count: {word_count} (must be 12, 15, 18, 21, or 24)"
124            )));
125        }
126
127        // Convert words to indices via binary search (wordlist is sorted)
128        let mut indices = Vec::with_capacity(word_count);
129        for word in &words {
130            let idx = wordlist.binary_search_by(|w| w.cmp(word)).map_err(|_| {
131                SignerError::InvalidPrivateKey(format!("unknown BIP-39 word: {word}"))
132            })?;
133            indices.push(idx as u32);
134        }
135
136        // Extract entropy bits
137        let total_bits = word_count * 11;
138        let cs_bits = word_count / 3; // CS = ENT/32, and word_count = (ENT + CS) / 11
139        let ent_bits = total_bits - cs_bits;
140        let ent_bytes = ent_bits / 8;
141
142        let mut entropy = vec![0u8; ent_bytes];
143        for (i, idx) in indices.iter().enumerate() {
144            for j in 0..11 {
145                let bit_pos = i * 11 + j;
146                if bit_pos < ent_bits {
147                    let bit = (idx >> (10 - j)) & 1;
148                    entropy[bit_pos / 8] |= (bit as u8) << (7 - (bit_pos % 8));
149                }
150            }
151        }
152
153        // Validate checksum
154        let hash = Sha256::digest(&entropy);
155        for i in 0..cs_bits {
156            let bit_pos = ent_bits + i;
157            let word_idx = bit_pos / 11;
158            let bit_in_word = bit_pos % 11;
159            let expected_bit = (indices[word_idx] >> (10 - bit_in_word)) & 1;
160            let actual_bit = u32::from((hash[i / 8] >> (7 - (i % 8))) & 1);
161            if expected_bit != actual_bit {
162                return Err(SignerError::InvalidPrivateKey(
163                    "invalid mnemonic checksum".into(),
164                ));
165            }
166        }
167
168        Ok(Self {
169            words: Zeroizing::new(phrase.to_string()),
170        })
171    }
172
173    /// Convert the mnemonic to a 64-byte seed using PBKDF2-SHA512.
174    ///
175    /// The passphrase is optional (use `""` for no passphrase).
176    pub fn to_seed(&self, passphrase: &str) -> Zeroizing<[u8; 64]> {
177        use zeroize::Zeroize;
178        let mut salt = format!("mnemonic{passphrase}");
179        let mut seed = Zeroizing::new([0u8; 64]);
180        pbkdf2::pbkdf2_hmac::<sha2::Sha512>(
181            self.words.as_bytes(),
182            salt.as_bytes(),
183            2048,
184            &mut *seed,
185        );
186        salt.zeroize();
187        seed
188    }
189
190    /// Return the mnemonic phrase as a string.
191    pub fn phrase(&self) -> &str {
192        &self.words
193    }
194
195    /// Return the number of words in the mnemonic.
196    pub fn word_count(&self) -> usize {
197        self.words.split_whitespace().count()
198    }
199
200    /// Derive an **Ethereum** signer from this mnemonic.
201    ///
202    /// Uses BIP-44 path `m/44'/60'/0'/0/{account_index}`.
203    #[cfg(feature = "ethereum")]
204    pub fn to_ethereum_signer(
205        &self,
206        passphrase: &str,
207        account_index: u32,
208    ) -> Result<crate::ethereum::EthereumSigner, SignerError> {
209        use crate::traits::KeyPair;
210        let seed = self.to_seed(passphrase);
211        let master = crate::hd_key::ExtendedPrivateKey::from_seed(&*seed)?;
212        let child = master.derive_path(&crate::hd_key::DerivationPath::ethereum(account_index))?;
213        crate::ethereum::EthereumSigner::from_bytes(&child.private_key_bytes())
214    }
215
216    /// Derive a **Bitcoin** signer from this mnemonic.
217    ///
218    /// Uses BIP-44 path `m/44'/0'/0'/0/{account_index}`.
219    #[cfg(feature = "bitcoin")]
220    pub fn to_bitcoin_signer(
221        &self,
222        passphrase: &str,
223        account_index: u32,
224    ) -> Result<crate::bitcoin::BitcoinSigner, SignerError> {
225        use crate::traits::KeyPair;
226        let seed = self.to_seed(passphrase);
227        let master = crate::hd_key::ExtendedPrivateKey::from_seed(&*seed)?;
228        let child = master.derive_path(&crate::hd_key::DerivationPath::bitcoin(account_index))?;
229        crate::bitcoin::BitcoinSigner::from_bytes(&child.private_key_bytes())
230    }
231
232    /// Derive a **Solana** signer from this mnemonic.
233    ///
234    /// Uses BIP-44 path `m/44'/501'/{account_index}'/0'`.
235    ///
236    /// **Note**: Solana uses Ed25519, but BIP-32 derives secp256k1 keys.
237    /// This returns the raw 32-byte derived key as the Ed25519 seed, which is
238    /// the convention used by Phantom and other Solana wallets.
239    #[cfg(feature = "solana")]
240    pub fn to_solana_signer(
241        &self,
242        passphrase: &str,
243        account_index: u32,
244    ) -> Result<crate::solana::SolanaSigner, SignerError> {
245        use crate::traits::KeyPair;
246        let seed = self.to_seed(passphrase);
247        let master = crate::hd_key::ExtendedPrivateKey::from_seed(&*seed)?;
248        let child = master.derive_path(&crate::hd_key::DerivationPath::solana(account_index))?;
249        crate::solana::SolanaSigner::from_bytes(&child.private_key_bytes())
250    }
251
252    /// Derive an **XRP** ECDSA signer from this mnemonic.
253    ///
254    /// Uses BIP-44 path `m/44'/144'/0'/0/{account_index}`.
255    #[cfg(feature = "xrp")]
256    pub fn to_xrp_signer(
257        &self,
258        passphrase: &str,
259        account_index: u32,
260    ) -> Result<crate::xrp::XrpEcdsaSigner, SignerError> {
261        use crate::traits::KeyPair;
262        let seed = self.to_seed(passphrase);
263        let master = crate::hd_key::ExtendedPrivateKey::from_seed(&*seed)?;
264        let child = master.derive_path(&crate::hd_key::DerivationPath::xrp(account_index))?;
265        crate::xrp::XrpEcdsaSigner::from_bytes(&child.private_key_bytes())
266    }
267}
268
269#[cfg(test)]
270#[allow(clippy::unwrap_used, clippy::expect_used)]
271mod tests {
272    use super::*;
273
274    // BIP-39 Test Vector 1 (from BIP-39 spec)
275    // Entropy: 00000000000000000000000000000000
276    #[test]
277    fn test_bip39_vector1_12words() {
278        let entropy = hex::decode("00000000000000000000000000000000").unwrap();
279        let mnemonic = Mnemonic::from_entropy(&entropy).unwrap();
280        assert_eq!(
281            mnemonic.phrase(),
282            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
283        );
284        assert_eq!(mnemonic.word_count(), 12);
285    }
286
287    // BIP-39 Test Vector 2 (24 words)
288    // Entropy: 7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f
289    #[test]
290    fn test_bip39_vector2_24words() {
291        let entropy =
292            hex::decode("7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f")
293                .unwrap();
294        let mnemonic = Mnemonic::from_entropy(&entropy).unwrap();
295        assert_eq!(
296            mnemonic.phrase(),
297            "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title"
298        );
299    }
300
301    // BIP-39 Test Vector: seed derivation
302    #[test]
303    fn test_bip39_seed_vector() {
304        let entropy = hex::decode("00000000000000000000000000000000").unwrap();
305        let mnemonic = Mnemonic::from_entropy(&entropy).unwrap();
306        let seed = mnemonic.to_seed("TREZOR");
307        // Official BIP-39 test vector for 128-bit all-zero entropy + "TREZOR" passphrase
308        let expected = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04";
309        assert_eq!(hex::encode(*seed), expected);
310    }
311
312    // Round-trip: generate → phrase → parse → seed
313    #[test]
314    fn test_generate_parse_roundtrip_12() {
315        let m1 = Mnemonic::generate(12).unwrap();
316        let m2 = Mnemonic::from_phrase(m1.phrase()).unwrap();
317        assert_eq!(m1.phrase(), m2.phrase());
318        assert_eq!(*m1.to_seed(""), *m2.to_seed(""));
319    }
320
321    #[test]
322    fn test_generate_parse_roundtrip_24() {
323        let m1 = Mnemonic::generate(24).unwrap();
324        let m2 = Mnemonic::from_phrase(m1.phrase()).unwrap();
325        assert_eq!(m1.phrase(), m2.phrase());
326    }
327
328    #[test]
329    fn test_invalid_word_count() {
330        assert!(Mnemonic::generate(11).is_err());
331        assert!(Mnemonic::generate(13).is_err());
332    }
333
334    #[test]
335    fn test_invalid_entropy_length() {
336        assert!(Mnemonic::from_entropy(&[0u8; 15]).is_err());
337        assert!(Mnemonic::from_entropy(&[0u8; 33]).is_err());
338    }
339
340    #[test]
341    fn test_invalid_word_rejected() {
342        assert!(Mnemonic::from_phrase("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon zzzzz").is_err());
343    }
344
345    #[test]
346    fn test_bad_checksum_rejected() {
347        // Valid words but wrong checksum
348        assert!(Mnemonic::from_phrase("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon").is_err());
349    }
350
351    #[test]
352    fn test_passphrase_changes_seed() {
353        let m = Mnemonic::from_entropy(&[0u8; 16]).unwrap();
354        let s1 = m.to_seed("");
355        let s2 = m.to_seed("password");
356        assert_ne!(*s1, *s2);
357    }
358
359    // Integration: mnemonic → seed → HD key → ETH address
360    #[test]
361    fn test_mnemonic_to_eth_address() {
362        let entropy = hex::decode("00000000000000000000000000000000").unwrap();
363        let mnemonic = Mnemonic::from_entropy(&entropy).unwrap();
364        let seed = mnemonic.to_seed("TREZOR");
365        let master = crate::hd_key::ExtendedPrivateKey::from_seed(&*seed).unwrap();
366        let child = master
367            .derive_path(&crate::hd_key::DerivationPath::ethereum(0))
368            .unwrap();
369        assert_eq!(child.private_key_bytes().len(), 32);
370    }
371
372    #[test]
373    fn test_all_entropy_sizes() {
374        for size in [16, 20, 24, 28, 32] {
375            let entropy = vec![0xABu8; size];
376            let m = Mnemonic::from_entropy(&entropy).unwrap();
377            let expected_words = (size * 8 + size * 8 / 32) / 11;
378            assert_eq!(m.word_count(), expected_words);
379            // Verify round-trip
380            let m2 = Mnemonic::from_phrase(m.phrase()).unwrap();
381            assert_eq!(m.phrase(), m2.phrase());
382        }
383    }
384
385    // ─── BIP-39 Official Test Vectors (TREZOR reference) ────────
386
387    #[test]
388    fn test_bip39_vector_all_zeros_12() {
389        // BIP-39: 128-bit all-zeros entropy → known mnemonic
390        let entropy = hex::decode("00000000000000000000000000000000").unwrap();
391        let m = Mnemonic::from_entropy(&entropy).unwrap();
392        assert_eq!(
393            m.phrase(),
394            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
395        );
396    }
397
398    #[test]
399    fn test_bip39_vector_all_zeros_seed() {
400        // BIP-39: all-zeros 12-word with passphrase "TREZOR"
401        // Our PBKDF2-HMAC-SHA512 output (verified deterministic)
402        let entropy = hex::decode("00000000000000000000000000000000").unwrap();
403        let m = Mnemonic::from_entropy(&entropy).unwrap();
404        let seed = m.to_seed("TREZOR");
405        // Verify determinism: same inputs → same output
406        let seed2 = m.to_seed("TREZOR");
407        assert_eq!(*seed, *seed2);
408        // Verify length
409        assert_eq!(seed.len(), 64);
410        // Verify different passphrase gives different seed
411        let seed3 = m.to_seed("different");
412        assert_ne!(*seed, *seed3);
413    }
414
415    #[test]
416    fn test_bip39_vector_7f_entropy() {
417        let entropy = hex::decode("7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f").unwrap();
418        let m = Mnemonic::from_entropy(&entropy).unwrap();
419        assert_eq!(
420            m.phrase(),
421            "legal winner thank year wave sausage worth useful legal winner thank yellow"
422        );
423    }
424
425    #[test]
426    fn test_bip39_vector_7f_seed() {
427        let entropy = hex::decode("7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f").unwrap();
428        let m = Mnemonic::from_entropy(&entropy).unwrap();
429        let seed = m.to_seed("TREZOR");
430        assert_eq!(
431            hex::encode(*seed),
432            "2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607"
433        );
434    }
435
436    #[test]
437    fn test_bip39_vector_ff_12() {
438        let entropy = hex::decode("ffffffffffffffffffffffffffffffff").unwrap();
439        let m = Mnemonic::from_entropy(&entropy).unwrap();
440        assert_eq!(
441            m.phrase(),
442            "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
443        );
444    }
445
446    #[test]
447    fn test_bip39_vector_24_words() {
448        // 256-bit entropy → 24 words
449        let entropy =
450            hex::decode("0000000000000000000000000000000000000000000000000000000000000000")
451                .unwrap();
452        let m = Mnemonic::from_entropy(&entropy).unwrap();
453        assert_eq!(
454            m.phrase(),
455            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"
456        );
457    }
458
459    #[test]
460    fn test_bip39_vector_24_seed() {
461        let entropy =
462            hex::decode("0000000000000000000000000000000000000000000000000000000000000000")
463                .unwrap();
464        let m = Mnemonic::from_entropy(&entropy).unwrap();
465        let seed = m.to_seed("TREZOR");
466        assert_eq!(
467            hex::encode(*seed),
468            "bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8"
469        );
470    }
471
472    #[test]
473    fn test_bip39_from_phrase_round_trip() {
474        // From known phrase → seed → verify
475        let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
476        let m = Mnemonic::from_phrase(phrase).unwrap();
477        assert_eq!(m.phrase(), phrase);
478        assert_eq!(m.word_count(), 12);
479    }
480
481    // ─── Cross-Chain Mnemonic Integration ───────────────────────
482    // One mnemonic → derive signers for all supported chains → verify
483    // addresses are distinct and valid.
484
485    #[test]
486    fn test_cross_chain_mnemonic_derivation() {
487        // Use the well-known BIP-39 test vector: all-zeros entropy
488        let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
489        let m = Mnemonic::from_phrase(phrase).unwrap();
490        let seed = m.to_seed("");
491
492        // All chains should derive from the same seed
493        let master = crate::hd_key::ExtendedPrivateKey::from_seed(&*seed).unwrap();
494
495        // Ethereum: m/44'/60'/0'/0/0
496        let eth = master
497            .derive_path(&crate::hd_key::DerivationPath::ethereum(0))
498            .unwrap();
499        let eth_key = eth.private_key_bytes();
500        assert_eq!(eth_key.len(), 32);
501
502        // Bitcoin: m/44'/0'/0'/0/0
503        let btc = master
504            .derive_path(&crate::hd_key::DerivationPath::bitcoin(0))
505            .unwrap();
506        let btc_key = btc.private_key_bytes();
507        assert_eq!(btc_key.len(), 32);
508
509        // Solana: m/44'/501'/0'/0'
510        let sol = master
511            .derive_path(&crate::hd_key::DerivationPath::solana(0))
512            .unwrap();
513        let sol_key = sol.private_key_bytes();
514        assert_eq!(sol_key.len(), 32);
515
516        // XRP: m/44'/144'/0'/0/0
517        let xrp = master
518            .derive_path(&crate::hd_key::DerivationPath::xrp(0))
519            .unwrap();
520        let xrp_key = xrp.private_key_bytes();
521        assert_eq!(xrp_key.len(), 32);
522
523        // All derived keys must be different (different BIP-44 coin types)
524        assert_ne!(&*eth_key, &*btc_key, "ETH != BTC");
525        assert_ne!(&*eth_key, &*sol_key, "ETH != SOL");
526        assert_ne!(&*eth_key, &*xrp_key, "ETH != XRP");
527        assert_ne!(&*btc_key, &*sol_key, "BTC != SOL");
528        assert_ne!(&*btc_key, &*xrp_key, "BTC != XRP");
529        assert_ne!(&*sol_key, &*xrp_key, "SOL != XRP");
530
531        // Derive same key twice → deterministic
532        let eth2 = master
533            .derive_path(&crate::hd_key::DerivationPath::ethereum(0))
534            .unwrap();
535        assert_eq!(&*eth_key, &*eth2.private_key_bytes());
536    }
537
538    #[cfg(feature = "ethereum")]
539    #[test]
540    fn test_cross_chain_mnemonic_eth_address() {
541        let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
542        let m = Mnemonic::from_phrase(phrase).unwrap();
543        let signer = m.to_ethereum_signer("", 0).unwrap();
544        let addr = signer.address_checksum();
545        assert!(
546            addr.starts_with("0x"),
547            "ETH address must start with 0x: {addr}"
548        );
549        assert_eq!(addr.len(), 42, "ETH address must be 42 chars");
550
551        // Second account should produce a different address
552        let signer1 = m.to_ethereum_signer("", 1).unwrap();
553        let addr1 = signer1.address_checksum();
554        assert_ne!(
555            addr, addr1,
556            "different account indices → different addresses"
557        );
558    }
559
560    #[cfg(feature = "bitcoin")]
561    #[test]
562    fn test_cross_chain_mnemonic_btc_signer() {
563        let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
564        let m = Mnemonic::from_phrase(phrase).unwrap();
565        let signer = m.to_bitcoin_signer("", 0).unwrap();
566        use crate::traits::Signer;
567        assert_eq!(signer.public_key_bytes().len(), 33); // compressed
568    }
569
570    #[cfg(feature = "solana")]
571    #[test]
572    fn test_cross_chain_mnemonic_sol_signer() {
573        let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
574        let m = Mnemonic::from_phrase(phrase).unwrap();
575        let signer = m.to_solana_signer("", 0).unwrap();
576        use crate::traits::Signer;
577        assert_eq!(signer.public_key_bytes().len(), 32); // Ed25519
578    }
579
580    #[cfg(feature = "xrp")]
581    #[test]
582    fn test_cross_chain_mnemonic_xrp_signer() {
583        let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
584        let m = Mnemonic::from_phrase(phrase).unwrap();
585        let signer = m.to_xrp_signer("", 0).unwrap();
586        use crate::traits::Signer;
587        assert_eq!(signer.public_key_bytes().len(), 33); // compressed secp256k1
588    }
589}