docs: update BLOCKERS.md with Task 3 completion status

This commit is contained in:
Kayos 2026-03-27 10:39:53 -07:00
parent db4c262b27
commit 19637833a6
8 changed files with 1230 additions and 75 deletions

View file

@ -0,0 +1,208 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.seedphrase
import com.bloxbean.cardano.client.crypto.bip39.MnemonicCode
import com.bloxbean.cardano.client.crypto.bip39.Words
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import timber.log.Timber
import java.security.SecureRandom
/**
* Result of seed phrase validation.
*/
sealed class SeedPhraseValidationResult {
data class Valid(val wordCount: Int) : SeedPhraseValidationResult()
data class Invalid(val error: String) : SeedPhraseValidationResult()
}
/**
* Manages BIP-39 seed phrase generation, validation, and display.
*
* ## Security Requirements for UI
* When displaying seed phrases in the UI:
* - Apply `FLAG_SECURE` to prevent screenshots: `window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)`
* - Clear the word list from memory when the screen is dismissed
* - Never log seed phrases
*
* ## Supported Word Counts
* - 12 words (128-bit entropy) - Standard for many wallets
* - 15 words (160-bit entropy)
* - 18 words (192-bit entropy)
* - 21 words (224-bit entropy)
* - 24 words (256-bit entropy) - Maximum security, used by default
*/
interface SeedPhraseManager {
/**
* Generates a new 24-word BIP-39 mnemonic.
*
* @return A list of 24 words from the BIP-39 English wordlist
*/
fun generateSeedPhrase(): List<String>
/**
* Generates a seed phrase with a specific word count.
*
* @param wordCount Must be 12, 15, 18, 21, or 24
* @return A list of words from the BIP-39 English wordlist
* @throws IllegalArgumentException if wordCount is invalid
*/
fun generateSeedPhrase(wordCount: Int): List<String>
/**
* Validates a seed phrase.
*
* Checks:
* 1. Word count (12, 15, 18, 21, or 24)
* 2. All words are in the BIP-39 English wordlist
* 3. Checksum is valid
*
* @param words The seed phrase as a list of words
* @return Validation result
*/
fun validate(words: List<String>): SeedPhraseValidationResult
/**
* Validates a seed phrase from a space-separated string.
*
* @param seedPhrase The seed phrase as a space-separated string
* @return Validation result
*/
fun validate(seedPhrase: String): SeedPhraseValidationResult
/**
* Normalizes a seed phrase input.
* - Trims whitespace
* - Lowercases all words
* - Removes extra spaces
*
* @param input Raw user input
* @return Normalized word list
*/
fun normalize(input: String): List<String>
/**
* Gets the BIP-39 English wordlist for autocomplete.
*/
fun getWordlist(): List<String>
/**
* Suggests words from the wordlist that start with the given prefix.
*
* @param prefix The prefix to match
* @param limit Maximum number of suggestions
* @return List of matching words
*/
fun suggestWords(prefix: String, limit: Int = 5): List<String>
}
/**
* Default implementation using cardano-client-lib.
*/
@ContributesBinding(AppScope::class)
class DefaultSeedPhraseManager @Inject constructor() : SeedPhraseManager {
companion object {
private const val DEFAULT_WORD_COUNT = 24
private val VALID_WORD_COUNTS = setOf(12, 15, 18, 21, 24)
private val ENTROPY_BITS_MAP = mapOf(
12 to 128,
15 to 160,
18 to 192,
21 to 224,
24 to 256,
)
}
private val mnemonicCode = MnemonicCode()
private val wordList: List<String> by lazy {
Words.ENGLISH.words.toList()
}
override fun generateSeedPhrase(): List<String> {
return generateSeedPhrase(DEFAULT_WORD_COUNT)
}
override fun generateSeedPhrase(wordCount: Int): List<String> {
require(wordCount in VALID_WORD_COUNTS) {
"Invalid word count: $wordCount. Must be one of: $VALID_WORD_COUNTS"
}
val entropyBits = ENTROPY_BITS_MAP[wordCount]
?: throw IllegalStateException("Missing entropy mapping for word count: $wordCount")
val entropyBytes = entropyBits / 8
val entropy = ByteArray(entropyBytes)
SecureRandom().nextBytes(entropy)
val words = try {
mnemonicCode.toMnemonic(entropy)
} finally {
// Clear entropy immediately
entropy.fill(0)
}
Timber.d("Generated $wordCount-word seed phrase")
return words
}
override fun validate(words: List<String>): SeedPhraseValidationResult {
// Check word count
if (words.size !in VALID_WORD_COUNTS) {
return SeedPhraseValidationResult.Invalid(
"Invalid word count: ${words.size}. Expected one of: $VALID_WORD_COUNTS"
)
}
// Check all words are in wordlist
val invalidWords = words.filter { it.lowercase() !in wordList }
if (invalidWords.isNotEmpty()) {
return SeedPhraseValidationResult.Invalid(
"Invalid words: ${invalidWords.joinToString(", ")}"
)
}
// Validate checksum
return try {
mnemonicCode.check(words.map { it.lowercase() })
SeedPhraseValidationResult.Valid(words.size)
} catch (e: Exception) {
SeedPhraseValidationResult.Invalid("Invalid checksum: ${e.message}")
}
}
override fun validate(seedPhrase: String): SeedPhraseValidationResult {
val words = normalize(seedPhrase)
return validate(words)
}
override fun normalize(input: String): List<String> {
return input
.trim()
.lowercase()
.split(Regex("\\s+"))
.filter { it.isNotBlank() }
}
override fun getWordlist(): List<String> {
return wordList
}
override fun suggestWords(prefix: String, limit: Int): List<String> {
if (prefix.isBlank()) {
return emptyList()
}
val normalizedPrefix = prefix.trim().lowercase()
return wordList
.filter { it.startsWith(normalizedPrefix) }
.take(limit)
}
}

View file

@ -0,0 +1,312 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.storage
import android.content.Context
import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.util.Base64
import com.bloxbean.cardano.client.account.Account
import com.bloxbean.cardano.client.crypto.bip39.MnemonicCode
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
import io.element.android.features.wallet.api.storage.WalletCreationResult
import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.security.KeyStore
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
/**
* Implementation of [CardanoKeyStorage] using Android Keystore for secure key management.
*
* ## Security Design
* - Mnemonic is encrypted with AES-GCM using an Android Keystore-backed key
* - Keystore key requires biometric/PIN authentication for every operation
* - Keys are invalidated if biometric enrollment changes
* - Per-session isolation via unique key aliases
*
* ## Storage Layout
* - SharedPreferences: `cardano_wallet_storage`
* - `encrypted_mnemonic_{sessionId}`: Base64-encoded encrypted mnemonic
* - `iv_{sessionId}`: Base64-encoded initialization vector
* - Android Keystore:
* - Alias: `cardano_wallet_{sessionId}`
*/
@ContributesBinding(AppScope::class)
class CardanoKeyStorageImpl @Inject constructor(
@ApplicationContext private val context: Context,
) : CardanoKeyStorage {
companion object {
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val PREFS_NAME = "cardano_wallet_storage"
private const val KEY_ENCRYPTED_MNEMONIC_PREFIX = "encrypted_mnemonic_"
private const val KEY_IV_PREFIX = "iv_"
private const val KEYSTORE_ALIAS_PREFIX = "cardano_wallet_"
private const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_TAG_LENGTH = 128
private const val GCM_IV_LENGTH = 12
private const val AES_KEY_SIZE = 256
private const val MNEMONIC_WORD_COUNT = 24
private const val MNEMONIC_ENTROPY_BYTES = 32 // 256 bits for 24 words
}
private val keyStore: KeyStore by lazy {
KeyStore.getInstance(ANDROID_KEYSTORE).apply {
load(null)
}
}
private val prefs: SharedPreferences by lazy {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
override suspend fun hasWallet(sessionId: SessionId): Boolean = withContext(Dispatchers.IO) {
val key = KEY_ENCRYPTED_MNEMONIC_PREFIX + sanitizeSessionId(sessionId)
prefs.contains(key)
}
override suspend fun generateWallet(sessionId: SessionId): Result<WalletCreationResult> =
withContext(Dispatchers.IO) {
runCatching {
if (hasWallet(sessionId)) {
throw IllegalStateException("Wallet already exists for session: ${sessionId.value}")
}
// Generate 256-bit entropy for 24-word mnemonic
val entropy = ByteArray(MNEMONIC_ENTROPY_BYTES)
SecureRandom().nextBytes(entropy)
// Generate mnemonic using cardano-client-lib
val mnemonicCode = MnemonicCode()
val wordList = mnemonicCode.toMnemonic(entropy)
// Clear entropy after use
entropy.fill(0)
// Store encrypted mnemonic
storeMnemonic(sessionId, wordList)
// Derive addresses
val mnemonicString = wordList.joinToString(" ")
val account = Account(CardanoNetworkConfig.getNetworks(), mnemonicString)
val result = WalletCreationResult(
mnemonic = wordList,
baseAddress = account.baseAddress(),
stakeAddress = account.stakeAddress(),
)
Timber.i("Generated new Cardano wallet for session: ${sessionId.value}")
result
}
}
override suspend fun importWallet(sessionId: SessionId, mnemonic: List<String>): Result<String> =
withContext(Dispatchers.IO) {
runCatching {
if (hasWallet(sessionId)) {
throw IllegalStateException("Wallet already exists for session: ${sessionId.value}")
}
// Validate mnemonic length
require(mnemonic.size in listOf(12, 15, 18, 21, 24)) {
"Invalid mnemonic length: ${mnemonic.size} words. Expected 12, 15, 18, 21, or 24."
}
// Validate mnemonic checksum
val mnemonicCode = MnemonicCode()
try {
mnemonicCode.check(mnemonic)
} catch (e: Exception) {
throw IllegalArgumentException("Invalid mnemonic: ${e.message}")
}
// Verify it produces valid Cardano addresses
val mnemonicString = mnemonic.joinToString(" ")
val account = try {
Account(CardanoNetworkConfig.getNetworks(), mnemonicString)
} catch (e: Exception) {
throw IllegalArgumentException("Failed to derive Cardano keys: ${e.message}")
}
// Store encrypted mnemonic
storeMnemonic(sessionId, mnemonic)
Timber.i("Imported Cardano wallet for session: ${sessionId.value}")
account.baseAddress()
}
}
override suspend fun getMnemonic(sessionId: SessionId): Result<List<String>> =
withContext(Dispatchers.IO) {
runCatching {
retrieveMnemonic(sessionId)
}
}
override suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int): Result<String> =
withContext(Dispatchers.IO) {
runCatching {
val mnemonic = retrieveMnemonic(sessionId)
val mnemonicString = mnemonic.joinToString(" ")
val account = Account(CardanoNetworkConfig.getNetworks(), mnemonicString, addressIndex)
account.baseAddress()
}
}
override suspend fun getStakeAddress(sessionId: SessionId): Result<String> =
withContext(Dispatchers.IO) {
runCatching {
val mnemonic = retrieveMnemonic(sessionId)
val mnemonicString = mnemonic.joinToString(" ")
val account = Account(CardanoNetworkConfig.getNetworks(), mnemonicString)
account.stakeAddress()
}
}
override suspend fun deleteWallet(sessionId: SessionId): Result<Unit> =
withContext(Dispatchers.IO) {
runCatching {
val sanitizedId = sanitizeSessionId(sessionId)
// Delete from SharedPreferences
prefs.edit()
.remove(KEY_ENCRYPTED_MNEMONIC_PREFIX + sanitizedId)
.remove(KEY_IV_PREFIX + sanitizedId)
.apply()
// Delete Keystore key
val alias = KEYSTORE_ALIAS_PREFIX + sanitizedId
if (keyStore.containsAlias(alias)) {
keyStore.deleteEntry(alias)
}
Timber.i("Deleted Cardano wallet for session: ${sessionId.value}")
}
}
/**
* Creates or retrieves an AES key from Android Keystore with strict security requirements.
*/
private fun getOrCreateSecretKey(sessionId: SessionId): SecretKey {
val alias = KEYSTORE_ALIAS_PREFIX + sanitizeSessionId(sessionId)
// Check if key exists
val existingKey = keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry
if (existingKey != null) {
return existingKey.secretKey
}
// Generate new key with strict security parameters
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
val keySpec = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(AES_KEY_SIZE)
// Require user authentication for every crypto operation
.setUserAuthenticationRequired(true)
// Auth required every time (no grace period)
.setUserAuthenticationValidityDurationSeconds(-1)
// CRITICAL: Invalidate key if biometric enrollment changes
.setInvalidatedByBiometricEnrollment(true)
.build()
keyGenerator.init(keySpec)
return keyGenerator.generateKey()
}
/**
* Encrypts and stores the mnemonic.
*/
private fun storeMnemonic(sessionId: SessionId, mnemonic: List<String>) {
val sanitizedId = sanitizeSessionId(sessionId)
val secretKey = getOrCreateSecretKey(sessionId)
// Encrypt mnemonic
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val mnemonicBytes = mnemonic.joinToString(" ").toByteArray(Charsets.UTF_8)
val encryptedBytes = cipher.doFinal(mnemonicBytes)
// Clear plaintext immediately
mnemonicBytes.fill(0)
// Store encrypted data and IV
prefs.edit()
.putString(KEY_ENCRYPTED_MNEMONIC_PREFIX + sanitizedId, Base64.encodeToString(encryptedBytes, Base64.NO_WRAP))
.putString(KEY_IV_PREFIX + sanitizedId, Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
.apply()
}
/**
* Retrieves and decrypts the mnemonic.
*
* @throws KeyPermanentlyInvalidatedException if biometrics changed
* @throws IllegalStateException if no wallet exists
*/
private fun retrieveMnemonic(sessionId: SessionId): List<String> {
val sanitizedId = sanitizeSessionId(sessionId)
val encryptedB64 = prefs.getString(KEY_ENCRYPTED_MNEMONIC_PREFIX + sanitizedId, null)
?: throw IllegalStateException("No wallet found for session: ${sessionId.value}")
val ivB64 = prefs.getString(KEY_IV_PREFIX + sanitizedId, null)
?: throw IllegalStateException("Missing IV for session: ${sessionId.value}")
val encryptedBytes = Base64.decode(encryptedB64, Base64.NO_WRAP)
val iv = Base64.decode(ivB64, Base64.NO_WRAP)
val secretKey = try {
getOrCreateSecretKey(sessionId)
} catch (e: KeyPermanentlyInvalidatedException) {
// Biometric enrollment changed - wallet is invalidated
Timber.e(e, "Key invalidated due to biometric change for session: ${sessionId.value}")
throw e
}
// Decrypt
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
val decryptedBytes = cipher.doFinal(encryptedBytes)
val mnemonicString = String(decryptedBytes, Charsets.UTF_8)
// Clear decrypted bytes
decryptedBytes.fill(0)
return mnemonicString.split(" ")
}
/**
* Sanitizes session ID for use in file/key names.
* Removes special characters that could cause issues.
*/
private fun sanitizeSessionId(sessionId: SessionId): String {
return sessionId.value
.replace("@", "")
.replace(":", "_")
.replace(".", "_")
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.cardano
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class CardanoNetworkConfigTest {
@Test
fun `network is configured as testnet`() {
// Verify we're on testnet by default (as per Phase 1 requirements)
assertThat(CardanoNetworkConfig.NETWORK).isEqualTo(CardanoNetwork.TESTNET)
}
@Test
fun `testnet has network ID 0`() {
// Testnet network ID should be 0
assertThat(CardanoNetworkConfig.NETWORK_ID).isEqualTo(0)
}
@Test
fun `testnet uses preprod Koios URL`() {
assertThat(CardanoNetworkConfig.KOIOS_BASE_URL).isEqualTo("https://preprod.koios.rest/api/v1")
}
@Test
fun `testnet uses preprod CardanoScan`() {
assertThat(CardanoNetworkConfig.EXPLORER_BASE_URL).isEqualTo("https://preprod.cardanoscan.io")
}
@Test
fun `testnet address prefix is addr_test1`() {
assertThat(CardanoNetworkConfig.ADDRESS_PREFIX).isEqualTo("addr_test1")
}
@Test
fun `network name is Preprod Testnet`() {
assertThat(CardanoNetworkConfig.NETWORK_NAME).isEqualTo("Preprod Testnet")
}
@Test
fun `getNetworks returns preprod network`() {
val networks = CardanoNetworkConfig.getNetworks()
// Preprod network has protocol magic 1
assertThat(networks.protocolMagic).isEqualTo(1)
}
}

View file

@ -0,0 +1,121 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.cardano
import com.google.common.truth.Truth.assertThat
import io.element.android.features.wallet.test.storage.FakeCardanoKeyStorage
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
class CardanoWalletManagerTest {
private lateinit var fakeKeyStorage: FakeCardanoKeyStorage
private lateinit var walletManager: DefaultCardanoWalletManager
private val testSessionId = UserId("@test:matrix.org")
@Before
fun setUp() {
fakeKeyStorage = FakeCardanoKeyStorage()
walletManager = DefaultCardanoWalletManager(fakeKeyStorage)
}
@Test
fun `initial state has no wallet`() = runTest {
val state = walletManager.walletState.value
assertThat(state.hasWallet).isFalse()
assertThat(state.address).isNull()
assertThat(state.isLoading).isTrue()
}
@Test
fun `initialize sets hasWallet false when no wallet exists`() = runTest {
walletManager.initialize(testSessionId)
val state = walletManager.walletState.value
assertThat(state.hasWallet).isFalse()
assertThat(state.isLoading).isFalse()
assertThat(state.error).isNull()
}
@Test
fun `initialize loads wallet when it exists`() = runTest {
// Create a wallet first
fakeKeyStorage.generateWallet(testSessionId)
walletManager.initialize(testSessionId)
val state = walletManager.walletState.value
assertThat(state.hasWallet).isTrue()
assertThat(state.address).isEqualTo(fakeKeyStorage.testBaseAddress)
assertThat(state.isLoading).isFalse()
}
@Test
fun `initialize sets error on failure`() = runTest {
fakeKeyStorage.getAddressError = RuntimeException("Storage error")
fakeKeyStorage.generateWallet(testSessionId)
walletManager.initialize(testSessionId)
val state = walletManager.walletState.value
assertThat(state.error).isNotNull()
assertThat(state.isLoading).isFalse()
}
@Test
fun `getAddress returns address from storage`() = runTest {
fakeKeyStorage.generateWallet(testSessionId)
val result = walletManager.getAddress(testSessionId)
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrNull()).isEqualTo(fakeKeyStorage.testBaseAddress)
}
@Test
fun `getStakeAddress returns stake address from storage`() = runTest {
fakeKeyStorage.generateWallet(testSessionId)
val result = walletManager.getStakeAddress(testSessionId)
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrNull()).isEqualTo(fakeKeyStorage.testStakeAddress)
}
@Test
fun `getAddress returns error when no wallet exists`() = runTest {
val result = walletManager.getAddress(testSessionId)
assertThat(result.isFailure).isTrue()
}
@Test
fun `clearState resets to initial`() = runTest {
fakeKeyStorage.generateWallet(testSessionId)
walletManager.initialize(testSessionId)
walletManager.clearState()
val state = walletManager.walletState.value
assertThat(state.hasWallet).isFalse()
assertThat(state.isLoading).isTrue()
}
@Test
fun `different sessions have isolated wallets`() = runTest {
val session1 = UserId("@user1:matrix.org")
val session2 = UserId("@user2:matrix.org")
fakeKeyStorage.generateWallet(session1)
assertThat(fakeKeyStorage.hasWallet(session1)).isTrue()
assertThat(fakeKeyStorage.hasWallet(session2)).isFalse()
}
}

View file

@ -0,0 +1,171 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.seedphrase
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
class SeedPhraseManagerTest {
private lateinit var seedPhraseManager: SeedPhraseManager
@Before
fun setUp() {
seedPhraseManager = DefaultSeedPhraseManager()
}
@Test
fun `generateSeedPhrase creates 24 words by default`() {
val words = seedPhraseManager.generateSeedPhrase()
assertThat(words).hasSize(24)
}
@Test
fun `generateSeedPhrase creates valid BIP-39 mnemonic`() {
val words = seedPhraseManager.generateSeedPhrase()
val result = seedPhraseManager.validate(words)
assertThat(result).isInstanceOf(SeedPhraseValidationResult.Valid::class.java)
}
@Test
fun `generateSeedPhrase with 12 words creates valid mnemonic`() {
val words = seedPhraseManager.generateSeedPhrase(12)
assertThat(words).hasSize(12)
val result = seedPhraseManager.validate(words)
assertThat(result).isInstanceOf(SeedPhraseValidationResult.Valid::class.java)
}
@Test
fun `generateSeedPhrase with invalid word count throws`() {
try {
seedPhraseManager.generateSeedPhrase(13)
assertThat(false).isTrue() // Should not reach here
} catch (e: IllegalArgumentException) {
assertThat(e.message).contains("Invalid word count")
}
}
@Test
fun `validate returns Valid for correct mnemonic`() {
// Known valid test mnemonic
val validMnemonic = listOf(
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "abandon", "about"
)
val result = seedPhraseManager.validate(validMnemonic)
assertThat(result).isInstanceOf(SeedPhraseValidationResult.Valid::class.java)
assertThat((result as SeedPhraseValidationResult.Valid).wordCount).isEqualTo(12)
}
@Test
fun `validate returns Invalid for wrong word count`() {
val invalidMnemonic = listOf("abandon", "abandon", "abandon")
val result = seedPhraseManager.validate(invalidMnemonic)
assertThat(result).isInstanceOf(SeedPhraseValidationResult.Invalid::class.java)
assertThat((result as SeedPhraseValidationResult.Invalid).error).contains("word count")
}
@Test
fun `validate returns Invalid for invalid words`() {
val invalidMnemonic = listOf(
"notaword", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "abandon", "about"
)
val result = seedPhraseManager.validate(invalidMnemonic)
assertThat(result).isInstanceOf(SeedPhraseValidationResult.Invalid::class.java)
assertThat((result as SeedPhraseValidationResult.Invalid).error).contains("notaword")
}
@Test
fun `validate returns Invalid for bad checksum`() {
// Valid words but invalid checksum
val invalidMnemonic = listOf(
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon"
)
val result = seedPhraseManager.validate(invalidMnemonic)
assertThat(result).isInstanceOf(SeedPhraseValidationResult.Invalid::class.java)
assertThat((result as SeedPhraseValidationResult.Invalid).error).contains("checksum")
}
@Test
fun `validate string input works`() {
val validMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
val result = seedPhraseManager.validate(validMnemonic)
assertThat(result).isInstanceOf(SeedPhraseValidationResult.Valid::class.java)
}
@Test
fun `normalize handles extra whitespace`() {
val input = " abandon abandon abandon "
val result = seedPhraseManager.normalize(input)
assertThat(result).containsExactly("abandon", "abandon", "abandon")
}
@Test
fun `normalize lowercases words`() {
val input = "ABANDON Abandon aBaNdOn"
val result = seedPhraseManager.normalize(input)
assertThat(result).containsExactly("abandon", "abandon", "abandon")
}
@Test
fun `suggestWords returns matching words`() {
val suggestions = seedPhraseManager.suggestWords("aban")
assertThat(suggestions).contains("abandon")
}
@Test
fun `suggestWords respects limit`() {
val suggestions = seedPhraseManager.suggestWords("a", limit = 3)
assertThat(suggestions).hasSize(3)
}
@Test
fun `suggestWords returns empty for blank prefix`() {
val suggestions = seedPhraseManager.suggestWords("")
assertThat(suggestions).isEmpty()
}
@Test
fun `getWordlist returns non-empty list`() {
val wordlist = seedPhraseManager.getWordlist()
assertThat(wordlist).isNotEmpty()
assertThat(wordlist).hasSize(2048) // BIP-39 standard
}
@Test
fun `generated mnemonics are unique`() {
val mnemonic1 = seedPhraseManager.generateSeedPhrase()
val mnemonic2 = seedPhraseManager.generateSeedPhrase()
assertThat(mnemonic1).isNotEqualTo(mnemonic2)
}
}