docs: update BLOCKERS.md with Task 3 completion status
This commit is contained in:
parent
db4c262b27
commit
19637833a6
8 changed files with 1230 additions and 75 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(".", "_")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue