Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions key-wallet/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
4 changes: 2 additions & 2 deletions key-wallet/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

PR semantic type appears mismatched with code scope.

This PR is titled test(...), but these lines expose new public API (Language re-export/prelude), which is feature-level surface change rather than test-only. Please retitle (or split test-only vs API changes) to match semantic-title policy and reviewer expectations.

As per coding guidelines, PR title prefixes must accurately describe the change type; here the changes include public API additions, not only tests.

Also applies to: 73-73

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@key-wallet/src/lib.rs` at line 63, The PR is labeled as test-only but adds
public API surface (the re-exports pub use mnemonic::{Language, Mnemonic} and
the additional re-export referenced at line 73), so either retitle the PR to an
appropriate semantic prefix (e.g., "feat: expose mnemonic Language and
Mnemonic") or split the changes: move the public re-exports into a separate
API/feature PR and keep only test changes in the test PR; ensure the commit/PR
title reflects the inclusion of those public symbols.

Source: Coding guidelines

pub use seed::Seed;
pub use signer::{Signer, SignerMethod, TransactionCategory};
pub use utxo::Utxo;
Expand All @@ -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::*;
}
94 changes: 94 additions & 0 deletions key-wallet/src/mnemonic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<String>()
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.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 {
Expand Down Expand Up @@ -356,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
Expand Down Expand Up @@ -568,4 +623,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
));
}
}
Loading