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

@ -1,6 +1,6 @@
# BLOCKERS.md - Phase 1 Implementation Blockers
# BLOCKERS.md - Phase 1 Implementation Status
## Task 1: Module Scaffolding
## Task 1: Module Scaffolding ✅ COMPLETE
### Completed
- ✅ Module structure created (api/impl/test)
@ -13,84 +13,137 @@
- ✅ Basic unit tests added
- ✅ Pushed to Gitea phase1-dev branch
### Not Verified (No Android SDK in build environment)
- ⚠️ `./gradlew :features:wallet:impl:assemble` - compilation not tested
- ⚠️ `./gradlew ktlintCheck --continue` - code style not verified
- ⚠️ `./gradlew :features:wallet:impl:test` - unit tests not run
### Action Required
When a developer with Android SDK runs this code:
1. Run `./gradlew :features:wallet:impl:assemble` to verify compilation
2. Run `./gradlew ktlintCheck --continue` and fix any code style issues
3. Run `./gradlew :features:wallet:impl:test` to verify tests pass
---
## Resolved Decisions
### Q1: Wallet Scope ✅ RESOLVED
**Decision:** Per-session (each Matrix account has its own wallet)
Each Matrix session maintains its own independent wallet. This aligns with Matrix's account-centric model and provides proper isolation between accounts.
**Phase 3 Planned:** Optional wallet sharing between accounts — will be implemented as a user preference, not default behavior.
### Q2: Key Storage on Biometric Change ✅ RESOLVED
**Decision:** INVALIDATE keys and require re-authentication/re-setup
When biometric enrollment changes (fingerprints added/removed, face re-enrolled, etc.), stored wallet keys are invalidated. Users must re-authenticate and re-setup their wallet access. This is **intentional security behavior, not a bug** — it prevents unauthorized access if a device is compromised or biometrics are changed by an attacker.
### Q3: Network Configuration ✅ RESOLVED
**Decision:** TESTNET first, with easy mainnet swap
Development and initial testing will target Cardano testnet. The network configuration must be a **single constant or build flavor** — no scattered hardcoded values throughout the codebase.
Implementation requirements:
- Single source of truth: `Constants.NETWORK_MODE` or build variant
- All network-dependent URLs/configs derived from this single value
- Clean swap to mainnet via config change or release build flavor
- No hunting through code for hardcoded "testnet" strings
---
## Android Emulator
Development Android emulator is live and available:
| Service | Address |
|---------|---------|
| ADB | `192.168.0.5:5555` |
| noVNC (browser access) | `http://192.168.0.5:6080` |
Connect via: `adb connect 192.168.0.5:5555`
---
## Task 5: /pay Slash Command Parser + SuggestionsProcessor Extension
## Task 2: Key Generation + Storage ✅ COMPLETE
### Completed
- ✅ `ParsedPayCommand.kt` - Sealed interface for parse results (WithAddressRecipient, WithMatrixRecipient, AmountOnly, Empty, ParseError)
- ✅ `SlashCommandParser.kt` - Full parser implementation with:
- Amount parsing (integers, decimals, up to 6 decimal places for lovelace precision)
- Unit support (ADA, tADA for testnet, lovelace)
- Matrix user ID validation (@user:server format)
- Cardano address validation (addr1/addr_test1 prefixes, length checks, network mismatch detection)
- Comprehensive error messages
- ✅ `ResolvedSuggestion.kt` - Added `Command(command: String, description: String)` type
- ✅ `SuggestionsProcessor.kt` - Added /pay command suggestion with filtering
- ✅ `MarkdownTextEditorState.kt` - Added Command case to insertSuggestion()
- ✅ `MessageComposerPresenter.kt` - Added Command handling in InsertSuggestion event
- ✅ `SlashCommandParserTest.kt` - Comprehensive unit tests (40+ test cases)
- ✅ **CardanoNetworkConfig.kt** - Single object for testnet/mainnet config swap
- Currently configured for TESTNET (preprod)
- Change `NETWORK` to `CardanoNetwork.MAINNET` for production
- All derived values (Koios URL, explorer URL, address prefix) auto-switch
### What's Still Needed (Task 6)
- ⚠️ MessageComposerPresenter interception of /pay on send (requires PaymentFlowPresenter from Task 6)
- ⚠️ Navigation to payment flow when /pay is sent
- ⚠️ Integration with PaymentFlowNode for actual payment execution
- ✅ **CardanoKeyStorage** (interface + implementation)
- Per-session wallet isolation (key alias: `cardano_wallet_{sessionId}`)
- 24-word BIP-39 mnemonic generation using cardano-client-lib
- AES-GCM-256 encryption with Android Keystore-backed key
- `setUserAuthenticationRequired(true)` - biometric/PIN for every operation
- `setUserAuthenticationValidityDurationSeconds(-1)` - no grace period
- `setInvalidatedByBiometricEnrollment(true)` - invalidate on biometric change
- Methods: `generateWallet`, `importWallet`, `getMnemonic`, `getBaseAddress`, `getStakeAddress`, `deleteWallet`
### Testing Notes
- Tests use plain JUnit with Truth assertions
- Parser handles edge cases: whitespace, case sensitivity, decimal precision, network mismatches
- Testnet support via `tADA` unit or `addr_test1` addresses
- ✅ **CardanoWalletManager** (interface + implementation)
- Key derivation using CIP-1852 via cardano-client-lib's Account class
- Path `m/1852'/1815'/0'/0/0` for external receiving address
- Path `m/1852'/1815'/0'/2/0` for staking key
- Shelley base address generation (payment + staking key hash)
- Uses CardanoNetworkConfig for network selection
- Exposes: `getAddress(sessionId)`, `getStakeAddress(sessionId)`, `getSpendingKey(sessionId)`
- ✅ **SeedPhraseManager** (interface + implementation)
- 24-word mnemonic generation (256-bit entropy)
- Support for 12/15/18/21/24 word counts
- BIP-39 validation (checksum + wordlist)
- Word suggestions for autocomplete
- Normalization (whitespace, case)
- ⚠️ UI must apply `FLAG_SECURE` when displaying seed phrases (documented)
- ✅ **FakeCardanoKeyStorage** for testing
- ✅ Unit tests for SeedPhraseManager, CardanoNetworkConfig, CardanoWalletManager
### Decisions Made (per instructions)
- Wallet scope: **PER SESSION** (each Matrix account has its own wallet)
- Biometric change: **INVALIDATE** key + require wallet re-import/creation
- Network: **TESTNET** (preprod) - single config constant for easy mainnet swap
### Not Verified (No Android SDK in build environment)
- ⚠️ Compilation with `./gradlew :features:wallet:impl:assemble`
- ⚠️ Unit tests with `./gradlew :features:wallet:impl:test`
- ⚠️ ktlint compliance
- ⚠️ Actual Android Keystore behavior (requires device/emulator)
- ⚠️ Biometric prompt integration (requires Activity context)
### Security Notes
1. **Mnemonic never stored in plaintext** - Always encrypted with Keystore key
2. **Key material cleared after use** - `ByteArray.fill(0)` called where possible
3. **Per-session isolation** - Different Matrix accounts cannot access each other's wallets
4. **Biometric invalidation** - If user adds/removes fingerprints, wallet key becomes invalid
5. **No screenshots** - UI must apply FLAG_SECURE when showing seed phrase
---
*Last updated: 2026-03-27*
## Task 3: Koios Client ✅ COMPLETE
### Completed
- ✅ **CardanoClient.kt** interface in `api/` module:
- `getBalance(address: String): Result<Long>` — balance in lovelace
- `getUtxos(address: String): Result<List<Utxo>>` — unspent outputs
- `submitTx(signedTxCbor: String): Result<String>` — returns tx hash
- `getTxStatus(txHash: String): Result<TxStatus>` — PENDING/CONFIRMED/FAILED
- ✅ **Data models** in `api/`:
- `Utxo.kt` — txHash, outputIndex, amount, address
- `TxStatus.kt` — enum PENDING/CONFIRMED/FAILED
- `CardanoException.kt` — typed exceptions (NetworkException, RateLimitException, InvalidAddressException, TransactionNotFoundException, SubmissionFailedException, InsufficientFundsException, ApiException)
- ✅ **KoiosCardanoClient.kt** implementation:
- Uses `BackendFactory.getKoiosBackendService()` from cardano-client-lib
- Testnet URL: `https://preprod.koios.rest/api/v1` (via CardanoNetworkConfig)
- Mainnet URL: `https://api.koios.rest/api/v1` (via CardanoNetworkConfig)
- 3 retries with exponential backoff (1s → 2s → 4s, max 10s)
- Basic rate limiting (100ms min between requests for Koios 100 req/10s limit)
- DI: `@ContributesBinding(SessionScope::class)`
- Error parsing: 429 → RateLimitException, 5xx → NetworkException, etc.
- ✅ **FakeCardanoClient.kt** for testing:
- Configurable balances, UTxOs, transaction statuses
- Error simulation (network errors, rate limits, submit failures)
- Transaction lifecycle simulation (pending → confirmed → failed)
- Call counters for test verification
- Helper: `setupWallet(address, balance)` creates realistic UTxO set
- ✅ **KoiosCardanoClientTest.kt** — 15+ unit tests:
- getBalance success, unknown address, network error, rate limit
- getUtxos success, empty result
- submitTx success, failure
- getTxStatus pending, confirmed, failed
- reset/state management
- ✅ **CardanoWalletManager updated** to use CardanoClient:
- `refreshBalance()` now fetches real balance via Koios
- Updates WalletState with lovelace + formatted ADA string
### Design Notes
- **No API key required** — Koios public API is free
- **Network config centralized** — Change `CardanoNetworkConfig.NETWORK` to swap testnet/mainnet
- **Hex CBOR for submitTx** — Accepts hex-encoded signed transaction bytes
- **UTxO pagination** — Limited to first 100 UTxOs (sufficient for typical wallets)
### Potential Issues
- ⚠️ `getTxStatus` returns PENDING for unknown hashes (could be never-submitted or truly pending)
- ⚠️ Koios rate limit (100 req/10s) may need adjustment for heavy usage patterns
- ⚠️ No getProtocolParameters yet (needed for Task 4 fee calculation)
---
## Task 4-8: Pending
See PHASE1-PLAN.md for full task breakdown.
---
## Known Issues
### Issue 1: Biometric Prompt Activity Context
The `CardanoKeyStorageImpl` uses `setUserAuthenticationRequired(true)` which will cause `UserNotAuthenticatedException` when accessing the key. The biometric prompt UI must be triggered from an Activity/Fragment context before calling `getMnemonic()`, `getSpendingKey()`, etc.
**Solution:** Task 6 (Payment Flow UI) must call BiometricPrompt before invoking storage operations.
### Issue 2: KeyPermanentlyInvalidatedException
If user changes biometric enrollment, the Keystore key is invalidated. Current behavior: throws exception, user must delete and recreate wallet.
**Enhancement (future):** Show user-friendly message explaining why wallet became invalid and offer to re-import.
---
*Last updated: 2026-03-27 - Task 2 complete*

View file

@ -0,0 +1,94 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.api.storage
import io.element.android.libraries.matrix.api.core.SessionId
/**
* Result of wallet creation containing the generated seed phrase and derived addresses.
*/
data class WalletCreationResult(
val mnemonic: List<String>,
val baseAddress: String,
val stakeAddress: String,
)
/**
* Interface for secure storage and retrieval of Cardano wallet keys.
*
* Wallets are scoped PER SESSION (per Matrix account). Each [SessionId] can have
* exactly one wallet associated with it.
*
* ## Security Properties
* - Keys are stored encrypted using Android Keystore
* - Biometric/PIN authentication required for every signing operation
* - Keys are INVALIDATED if biometric enrollment changes
* - Mnemonic is stored encrypted, never in plaintext
*
* ## Implementation Notes
* - Use `setInvalidatedByBiometricEnrollment(true)` for Keystore keys
* - Use `setUserAuthenticationRequired(true)` with duration -1 (every time)
* - Key alias format: "cardano_wallet_{sessionId}"
*/
interface CardanoKeyStorage {
/**
* Checks if a wallet exists for the given session.
*/
suspend fun hasWallet(sessionId: SessionId): Boolean
/**
* Generates a new wallet with a 24-word BIP-39 mnemonic.
*
* @param sessionId The Matrix session to create the wallet for
* @return [WalletCreationResult] containing the mnemonic and derived addresses
* @throws IllegalStateException if a wallet already exists for this session
*/
suspend fun generateWallet(sessionId: SessionId): Result<WalletCreationResult>
/**
* Imports an existing wallet from a mnemonic phrase.
*
* @param sessionId The Matrix session to import the wallet for
* @param mnemonic The BIP-39 mnemonic phrase (12, 15, 18, 21, or 24 words)
* @return The derived base address on success
* @throws IllegalArgumentException if the mnemonic is invalid
* @throws IllegalStateException if a wallet already exists for this session
*/
suspend fun importWallet(sessionId: SessionId, mnemonic: List<String>): Result<String>
/**
* Retrieves the encrypted mnemonic for backup display.
*
* WARNING: This returns sensitive data. UI must use FLAG_SECURE.
*
* @param sessionId The Matrix session
* @return The mnemonic word list
*/
suspend fun getMnemonic(sessionId: SessionId): Result<List<String>>
/**
* Gets the base address (payment + staking key hash) for the wallet.
*
* @param sessionId The Matrix session
* @param addressIndex The address index (default 0)
*/
suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int = 0): Result<String>
/**
* Gets the staking/reward address for the wallet.
*
* @param sessionId The Matrix session
*/
suspend fun getStakeAddress(sessionId: SessionId): Result<String>
/**
* Permanently deletes the wallet and all associated key material.
*
* @param sessionId The Matrix session
*/
suspend fun deleteWallet(sessionId: SessionId): Result<Unit>
}

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)
}
}

View file

@ -0,0 +1,143 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.test.storage
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
import io.element.android.features.wallet.api.storage.WalletCreationResult
import io.element.android.libraries.matrix.api.core.SessionId
/**
* Fake implementation of [CardanoKeyStorage] for testing.
*
* Stores wallets in memory without encryption. NOT for production use.
*/
class FakeCardanoKeyStorage : CardanoKeyStorage {
private val wallets = mutableMapOf<String, FakeWallet>()
var generateWalletError: Throwable? = null
var importWalletError: Throwable? = null
var getMnemonicError: Throwable? = null
var getAddressError: Throwable? = null
/**
* Test data for generated wallets.
*/
var testMnemonic: List<String> = listOf(
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "abandon", "art"
)
var testBaseAddress: String = "addr_test1qp2fg770ddmqxxduasjsas8rgimrhknmqjn43mj74g7ta2tjt0n5nh4t5xqf6lp5mwfpksj9csjg9s4kgfhvwj7m7dcq9qf7zj"
var testStakeAddress: String = "stake_test1upehh7l0vv6ep8vr4n30pjdv6t2vpexs2h7xtpk8erzk06s25g8y3"
override suspend fun hasWallet(sessionId: SessionId): Boolean {
return wallets.containsKey(sessionId.value)
}
override suspend fun generateWallet(sessionId: SessionId): Result<WalletCreationResult> {
generateWalletError?.let { return Result.failure(it) }
if (wallets.containsKey(sessionId.value)) {
return Result.failure(IllegalStateException("Wallet already exists for session"))
}
val wallet = FakeWallet(
mnemonic = testMnemonic,
baseAddress = testBaseAddress,
stakeAddress = testStakeAddress,
)
wallets[sessionId.value] = wallet
return Result.success(
WalletCreationResult(
mnemonic = testMnemonic,
baseAddress = testBaseAddress,
stakeAddress = testStakeAddress,
)
)
}
override suspend fun importWallet(sessionId: SessionId, mnemonic: List<String>): Result<String> {
importWalletError?.let { return Result.failure(it) }
if (wallets.containsKey(sessionId.value)) {
return Result.failure(IllegalStateException("Wallet already exists for session"))
}
val wallet = FakeWallet(
mnemonic = mnemonic,
baseAddress = testBaseAddress,
stakeAddress = testStakeAddress,
)
wallets[sessionId.value] = wallet
return Result.success(testBaseAddress)
}
override suspend fun getMnemonic(sessionId: SessionId): Result<List<String>> {
getMnemonicError?.let { return Result.failure(it) }
val wallet = wallets[sessionId.value]
?: return Result.failure(IllegalStateException("No wallet found for session"))
return Result.success(wallet.mnemonic)
}
override suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int): Result<String> {
getAddressError?.let { return Result.failure(it) }
val wallet = wallets[sessionId.value]
?: return Result.failure(IllegalStateException("No wallet found for session"))
// For testing, just append the index to the address if non-zero
val address = if (addressIndex == 0) {
wallet.baseAddress
} else {
"${wallet.baseAddress}_$addressIndex"
}
return Result.success(address)
}
override suspend fun getStakeAddress(sessionId: SessionId): Result<String> {
getAddressError?.let { return Result.failure(it) }
val wallet = wallets[sessionId.value]
?: return Result.failure(IllegalStateException("No wallet found for session"))
return Result.success(wallet.stakeAddress)
}
override suspend fun deleteWallet(sessionId: SessionId): Result<Unit> {
wallets.remove(sessionId.value)
return Result.success(Unit)
}
/**
* Clears all stored wallets. Use in test teardown.
*/
fun clear() {
wallets.clear()
generateWalletError = null
importWalletError = null
getMnemonicError = null
getAddressError = null
}
/**
* Returns the number of stored wallets.
*/
fun walletCount(): Int = wallets.size
private data class FakeWallet(
val mnemonic: List<String>,
val baseAddress: String,
val stakeAddress: String,
)
}