From 307582cf32c00b885b868130ae107dfe175c0a52 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 10 Jun 2026 18:10:06 +0200 Subject: [PATCH 1/2] feat(key-wallet): add normalize_phrase and is_word_in_language helpers Add two BIP39 input helpers to Mnemonic so consumers don't re-implement wordlist/normalization logic on top of the bip39 crate: - normalize_phrase: NFKD + lowercase + whitespace-collapse (lenient input normalization for validation/membership; NOT BIP39 seed normalization, which to_seed already performs) - is_word_in_language: exact wordlist membership for a given language Re-export Language from the crate root + prelude, and add the unicode-normalization dependency. Co-Authored-By: Claude Opus 4.8 --- key-wallet/Cargo.toml | 1 + key-wallet/src/lib.rs | 4 +-- key-wallet/src/mnemonic.rs | 63 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/key-wallet/Cargo.toml b/key-wallet/Cargo.toml index 2a0cbf779..ca4598a91 100644 --- a/key-wallet/Cargo.toml +++ b/key-wallet/Cargo.toml @@ -36,6 +36,7 @@ dashcore_hashes = { path = "../hashes" } dashcore = { path="../dash" } secp256k1 = { version = "0.30.0", default-features = false, features = ["hashes", "recovery", "std"] } bip39 = { version = "2.2.0", default-features = false, features = ["std", "chinese-simplified", "chinese-traditional", "czech", "french", "italian", "japanese", "korean", "portuguese", "spanish", "zeroize"] } +unicode-normalization = "0.1" serde = { version = "1.0", default-features = false, features = ["derive"], optional = true } base58ck = { version = "0.1.0", default-features = false } bitflags = { version = "2.6", default-features = false } diff --git a/key-wallet/src/lib.rs b/key-wallet/src/lib.rs index 4b5e89e15..a9cb892b3 100644 --- a/key-wallet/src/lib.rs +++ b/key-wallet/src/lib.rs @@ -60,7 +60,7 @@ pub use managed_account::address_pool::{AddressInfo, AddressPool, KeySource, Poo pub use managed_account::managed_account_type::ManagedAccountType; pub use managed_account::managed_platform_account::ManagedPlatformAccount; pub use managed_account::platform_address::PlatformP2PKHAddress; -pub use mnemonic::Mnemonic; +pub use mnemonic::{Language, Mnemonic}; pub use seed::Seed; pub use signer::{Signer, SignerMethod, TransactionCategory}; pub use utxo::Utxo; @@ -70,7 +70,7 @@ pub use wallet::{balance::WalletCoreBalance, Wallet}; pub mod prelude { pub use super::{ Address, AddressType, ChildNumber, DerivationPath, Error, ExtendedPrivKey, ExtendedPubKey, - KeyDerivation, Mnemonic, Result, + KeyDerivation, Language, Mnemonic, Result, }; pub use dashcore::prelude::*; } diff --git a/key-wallet/src/mnemonic.rs b/key-wallet/src/mnemonic.rs index fb25bc5f1..1b593b4bf 100644 --- a/key-wallet/src/mnemonic.rs +++ b/key-wallet/src/mnemonic.rs @@ -190,6 +190,30 @@ impl Mnemonic { pub fn validate(phrase: &str, language: Language) -> bool { bip39_crate::Mnemonic::parse_in(language.into(), phrase).is_ok() } + + /// Normalize a phrase for lenient validation / wordlist-membership input: + /// NFKD + lowercase + collapse every whitespace run to a single ASCII + /// space (ends trimmed). This is **input tolerance** for user-typed + /// phrases, NOT BIP39 seed normalization — [`Self::to_seed`] performs the + /// BIP39 (NFKD-only) normalization required for seed derivation. + pub fn normalize_phrase(input: &str) -> String { + use unicode_normalization::UnicodeNormalization; + input + .nfkd() + .collect::() + .to_lowercase() + .split_whitespace() + .collect::>() + .join(" ") + } + + /// Returns `true` if `word` is an exact member of `language`'s BIP39 + /// wordlist. Membership is exact (no normalization); pre-normalize the + /// input via [`Self::normalize_phrase`] for case/accent tolerance. + pub fn is_word_in_language(word: &str, language: Language) -> bool { + let lang: bip39_crate::Language = language.into(); + lang.word_list().contains(&word) + } } impl FromStr for Mnemonic { @@ -568,4 +592,43 @@ mod tests { } } } + + #[test] + fn test_normalize_phrase() { + assert_eq!( + Mnemonic::normalize_phrase(" ABANDON\tabout \n legal "), + "abandon about legal" + ); + assert_eq!(Mnemonic::normalize_phrase(""), ""); + assert_eq!(Mnemonic::normalize_phrase(" "), ""); + // Idempotent. + let once = Mnemonic::normalize_phrase(" ABANDON\tAbout "); + assert_eq!(Mnemonic::normalize_phrase(&once), once); + } + + #[test] + fn test_normalize_phrase_unicode_forms_converge() { + use unicode_normalization::UnicodeNormalization; + // NFC and NFD of the same accented text both normalize (NFKD) identically. + let nfc = "café au lait"; + let nfd: String = nfc.nfd().collect(); + assert_ne!(nfc, nfd.as_str()); + assert_eq!(Mnemonic::normalize_phrase(nfc), Mnemonic::normalize_phrase(&nfd)); + } + + #[test] + fn test_is_word_in_language() { + assert!(Mnemonic::is_word_in_language("abandon", Language::English)); + assert!(!Mnemonic::is_word_in_language("notaword", Language::English)); + // A Japanese wordlist entry is valid in Japanese, not in English. + let jp = bip39_crate::Language::Japanese.word_list()[0]; + assert!(Mnemonic::is_word_in_language(jp, Language::Japanese)); + assert!(!Mnemonic::is_word_in_language(jp, Language::English)); + // Exact membership: raw uppercase fails until normalized. + assert!(!Mnemonic::is_word_in_language("ABANDON", Language::English)); + assert!(Mnemonic::is_word_in_language( + &Mnemonic::normalize_phrase("ABANDON"), + Language::English + )); + } } From ff440e944fbbd0fd2c09eef5624c2cfd065db083 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 11 Jun 2026 15:46:45 +0200 Subject: [PATCH 2/2] test(key-wallet): validate real phrase for each bundled language Mnemonic::validate was exercised only for English (test_mnemonic_validation) and Portuguese (test_portuguese_mnemonic); the other bundled languages were built via from_entropy in test_multiple_languages but never validate()'d. Add a test that builds a real 12-word phrase per language (French, Spanish, Italian, Japanese, Korean, Czech, ChineseSimplified, ChineseTraditional) and asserts validate() succeeds, plus a cross-language negative. This brings the per-language validate coverage home to key-wallet (it previously lived only in the dashwallet platform FFI facade). Co-Authored-By: Claude Opus 4.8 --- key-wallet/src/mnemonic.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/key-wallet/src/mnemonic.rs b/key-wallet/src/mnemonic.rs index 1b593b4bf..4ac109419 100644 --- a/key-wallet/src/mnemonic.rs +++ b/key-wallet/src/mnemonic.rs @@ -380,6 +380,37 @@ mod tests { } // ✓ Test multiple languages (basic test that languages are supported) + #[test] + fn test_validate_real_phrase_each_language() { + // A real 12-word phrase in each bundled language validates in that + // language. Closes the gap where validate() was exercised only for + // English (test_mnemonic_validation) and Portuguese + // (test_portuguese_mnemonic); the other languages were only built via + // from_entropy in test_multiple_languages, never validate()'d. + let entropy = Vec::from_hex("00000000000000000000000000000000").unwrap(); + for language in [ + Language::French, + Language::Spanish, + Language::Italian, + Language::Japanese, + Language::Korean, + Language::Czech, + Language::ChineseSimplified, + Language::ChineseTraditional, + ] { + let phrase = Mnemonic::from_entropy(&entropy, language).unwrap().phrase(); + assert!( + Mnemonic::validate(&phrase, language), + "{language:?} phrase should validate in its own language" + ); + } + + // Cross-language negative: a French phrase is not valid as Japanese + // (disjoint wordlists). + let french = Mnemonic::from_entropy(&entropy, Language::French).unwrap().phrase(); + assert!(!Mnemonic::validate(&french, Language::Japanese)); + } + #[test] fn test_multiple_languages() { // English