diff --git a/BLOCKERS.md b/BLOCKERS.md index 88e39ec840..46ed7ed31e 100644 --- a/BLOCKERS.md +++ b/BLOCKERS.md @@ -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` — balance in lovelace + - `getUtxos(address: String): Result>` — unspent outputs + - `submitTx(signedTxCbor: String): Result` — returns tx hash + - `getTxStatus(txHash: String): Result` — 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* diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/storage/CardanoKeyStorage.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/storage/CardanoKeyStorage.kt new file mode 100644 index 0000000000..a36e64ebfd --- /dev/null +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/storage/CardanoKeyStorage.kt @@ -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, + 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 + + /** + * 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): Result + + /** + * 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> + + /** + * 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 + + /** + * Gets the staking/reward address for the wallet. + * + * @param sessionId The Matrix session + */ + suspend fun getStakeAddress(sessionId: SessionId): Result + + /** + * Permanently deletes the wallet and all associated key material. + * + * @param sessionId The Matrix session + */ + suspend fun deleteWallet(sessionId: SessionId): Result +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/seedphrase/SeedPhraseManager.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/seedphrase/SeedPhraseManager.kt new file mode 100644 index 0000000000..08d67d1294 --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/seedphrase/SeedPhraseManager.kt @@ -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 + + /** + * 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 + + /** + * 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): 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 + + /** + * Gets the BIP-39 English wordlist for autocomplete. + */ + fun getWordlist(): List + + /** + * 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 +} + +/** + * 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 by lazy { + Words.ENGLISH.words.toList() + } + + override fun generateSeedPhrase(): List { + return generateSeedPhrase(DEFAULT_WORD_COUNT) + } + + override fun generateSeedPhrase(wordCount: Int): List { + 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): 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 { + return input + .trim() + .lowercase() + .split(Regex("\\s+")) + .filter { it.isNotBlank() } + } + + override fun getWordlist(): List { + return wordList + } + + override fun suggestWords(prefix: String, limit: Int): List { + if (prefix.isBlank()) { + return emptyList() + } + + val normalizedPrefix = prefix.trim().lowercase() + return wordList + .filter { it.startsWith(normalizedPrefix) } + .take(limit) + } +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/storage/CardanoKeyStorageImpl.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/storage/CardanoKeyStorageImpl.kt new file mode 100644 index 0000000000..de7e741f2e --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/storage/CardanoKeyStorageImpl.kt @@ -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 = + 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): Result = + 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> = + withContext(Dispatchers.IO) { + runCatching { + retrieveMnemonic(sessionId) + } + } + + override suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int): Result = + 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 = + 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 = + 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) { + 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 { + 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(".", "_") + } +} diff --git a/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/cardano/CardanoNetworkConfigTest.kt b/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/cardano/CardanoNetworkConfigTest.kt new file mode 100644 index 0000000000..40415549c1 --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/cardano/CardanoNetworkConfigTest.kt @@ -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) + } +} diff --git a/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/cardano/CardanoWalletManagerTest.kt b/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/cardano/CardanoWalletManagerTest.kt new file mode 100644 index 0000000000..2738bd6b2e --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/cardano/CardanoWalletManagerTest.kt @@ -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() + } +} diff --git a/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/seedphrase/SeedPhraseManagerTest.kt b/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/seedphrase/SeedPhraseManagerTest.kt new file mode 100644 index 0000000000..733dc66ca5 --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/seedphrase/SeedPhraseManagerTest.kt @@ -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) + } +} diff --git a/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/storage/FakeCardanoKeyStorage.kt b/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/storage/FakeCardanoKeyStorage.kt new file mode 100644 index 0000000000..da51d8a978 --- /dev/null +++ b/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/storage/FakeCardanoKeyStorage.kt @@ -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() + + var generateWalletError: Throwable? = null + var importWalletError: Throwable? = null + var getMnemonicError: Throwable? = null + var getAddressError: Throwable? = null + + /** + * Test data for generated wallets. + */ + var testMnemonic: List = 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 { + 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): Result { + 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> { + 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 { + 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 { + 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 { + 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, + val baseAddress: String, + val stakeAddress: String, + ) +}