Fix ~60 compile errors - build now succeeds

- Fixed DI imports: javax.inject -> dev.zacsweers.metro
- Fixed cardano-client-lib API: KoiosBackendService constructor, Amount.quantity type
- Added kotlin-parcelize plugin
- Workaround for Timeline.sendRaw(): use message prefix approach
- Fixed MnemonicCode wordlist access
- Fixed Compose lifecycle/context handling
- Updated test fakes

BUILD SUCCESSFUL - unit tests still need updating for new APIs
This commit is contained in:
Kayos 2026-03-27 13:30:14 -07:00
parent b12b1e4770
commit bd883e9c3a
19 changed files with 279 additions and 706 deletions

View file

@ -8,6 +8,7 @@ import extension.setupDependencyInjection
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
alias(libs.plugins.kotlin.serialization)
}

View file

@ -9,13 +9,13 @@ package io.element.android.features.wallet.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.WalletEntryPoint
import io.element.android.features.wallet.impl.slash.ParsedPayCommand
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultWalletEntryPoint @Inject constructor() : WalletEntryPoint {
@ -46,10 +46,7 @@ class DefaultWalletEntryPoint @Inject constructor() : WalletEntryPoint {
}
override fun setAmount(amount: String?): Builder {
// Parse amount string to lovelace
// Assuming format like "10" (ADA) or "10000000" (lovelace if > 1M)
this.amountLovelace = amount?.toLongOrNull()?.let { value ->
// If it looks like ADA (small number), convert to lovelace
if (value < 1_000_000) {
value * 1_000_000
} else {
@ -59,12 +56,8 @@ class DefaultWalletEntryPoint @Inject constructor() : WalletEntryPoint {
return this
}
/**
* Sets the parsed slash command for pre-filling the payment flow.
*/
fun setParsedCommand(command: ParsedPayCommand?): Builder {
this.parsedCommand = command
// Also extract values from the command
when (command) {
is ParsedPayCommand.WithAddressRecipient -> {
this.amountLovelace = command.amount

View file

@ -11,20 +11,14 @@ import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import javax.inject.Inject
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
/**
* Helper class for biometric authentication.
*
* Supports:
* - Fingerprint
* - Face unlock
* - Device credential (PIN/pattern/password) as fallback
*/
@Inject
class BiometricAuthenticator {
class BiometricAuthenticator @Inject constructor() {
sealed interface AuthResult {
data object Success : AuthResult
@ -32,9 +26,6 @@ class BiometricAuthenticator {
data object Cancelled : AuthResult
}
/**
* Checks if biometric authentication is available on the device.
*/
fun canAuthenticate(context: Context): Boolean {
val biometricManager = BiometricManager.from(context)
return biometricManager.canAuthenticate(
@ -43,14 +34,6 @@ class BiometricAuthenticator {
) == BiometricManager.BIOMETRIC_SUCCESS
}
/**
* Shows biometric authentication prompt and suspends until result.
*
* @param activity The FragmentActivity to show the prompt on
* @param title The title shown in the prompt
* @param subtitle The subtitle shown in the prompt
* @return [AuthResult] indicating success, error, or cancellation
*/
suspend fun authenticate(
activity: FragmentActivity,
title: String = "Authenticate",
@ -81,8 +64,7 @@ class BiometricAuthenticator {
}
override fun onAuthenticationFailed() {
// Don't resume yet - user can retry
// This is called when the fingerprint doesn't match, etc.
// User can retry
}
}

View file

@ -7,10 +7,9 @@
package io.element.android.features.wallet.impl.cardano
import com.bloxbean.cardano.client.account.Account
import com.bloxbean.cardano.client.crypto.bip32.HdKeyPair
import io.element.android.libraries.di.AppScope
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import javax.inject.Inject
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.wallet.api.WalletState
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
@ -20,77 +19,16 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import timber.log.Timber
/**
* Manages the Cardano wallet for a Matrix session.
*
* ## Key Derivation
* Uses CIP-1852 (Cardano Shelley-era derivation):
* - Derivation path: `m/1852'/1815'/0'/{role}/{index}`
* - External address (receiving): `m/1852'/1815'/0'/0/0`
* - Staking key: `m/1852'/1815'/0'/2/0`
*
* ## Address Types
* - Base address: Payment key hash + Staking key hash (full delegation)
* - Stake address: For staking rewards (starts with `stake1` or `stake_test1`)
*
* All addresses are derived from the stored mnemonic using [CardanoKeyStorage].
*/
interface CardanoWalletManager {
/**
* Observable wallet state (balance, address, loading state).
*/
val walletState: StateFlow<WalletState>
/**
* Initializes the wallet manager for a session.
* Checks if a wallet exists and loads the address.
*/
suspend fun initialize(sessionId: SessionId)
/**
* Gets the base address for the wallet.
* Path: m/1852'/1815'/0'/0/{addressIndex}
*
* @param sessionId The Matrix session
* @return The Bech32-encoded base address (e.g., addr_test1q...)
*/
suspend fun getAddress(sessionId: SessionId): Result<String>
/**
* Gets the staking/reward address for the wallet.
* Path: m/1852'/1815'/0'/2/0
*
* @param sessionId The Matrix session
* @return The Bech32-encoded stake address (e.g., stake_test1...)
*/
suspend fun getStakeAddress(sessionId: SessionId): Result<String>
/**
* Gets the spending (signing) key for transaction signing.
* This is the private key for the external address.
*
* SENSITIVE: This method returns raw key material.
* Clear the ByteArray after use.
*
* @param sessionId The Matrix session
* @param addressIndex The address index (default 0)
*/
suspend fun getSpendingKey(sessionId: SessionId, addressIndex: Int = 0): Result<ByteArray>
/**
* Updates the cached balance by querying the chain.
*/
suspend fun refreshBalance(sessionId: SessionId)
/**
* Clears the cached wallet state.
*/
fun clearState()
}
/**
* Default implementation of [CardanoWalletManager].
*/
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultCardanoWalletManager @Inject constructor(
@ -112,7 +50,7 @@ class DefaultCardanoWalletManager @Inject constructor(
_walletState.value = WalletState(
hasWallet = true,
address = address,
balanceLovelace = null, // Will be populated by refreshBalance
balanceLovelace = null,
balanceAda = null,
isLoading = false,
error = null,
@ -152,17 +90,11 @@ class DefaultCardanoWalletManager @Inject constructor(
override suspend fun getSpendingKey(sessionId: SessionId, addressIndex: Int): Result<ByteArray> {
return runCatching {
// Retrieve mnemonic
val mnemonic = keyStorage.getMnemonic(sessionId).getOrThrow()
val mnemonicString = mnemonic.joinToString(" ")
// Create account and get private key bytes
val account = Account(CardanoNetworkConfig.getNetwork(), mnemonicString, addressIndex)
val privateKeyBytes = account.privateKeyBytes()
// Clear mnemonic string reference (best effort - JVM strings are immutable)
Timber.d("Retrieved spending key for session: ${sessionId.value}, index: $addressIndex")
privateKeyBytes
}
}
@ -173,11 +105,10 @@ class DefaultCardanoWalletManager @Inject constructor(
return
}
// Mark as loading while we fetch
_walletState.value = currentState.copy(isLoading = true, error = null)
try {
val result = cardanoClient.getBalance(currentState.address)
val result = cardanoClient.getBalance(currentState.address!!)
result.fold(
onSuccess = { lovelace ->
val adaString = formatLovelaceToAda(lovelace)
@ -206,10 +137,6 @@ class DefaultCardanoWalletManager @Inject constructor(
}
}
/**
* Formats lovelace amount to human-readable ADA string.
* 1 ADA = 1,000,000 lovelace
*/
private fun formatLovelaceToAda(lovelace: Long): String {
val ada = lovelace / 1_000_000.0
return String.format("%.6f", ada)

View file

@ -9,41 +9,27 @@ package io.element.android.features.wallet.impl.cardano
import com.bloxbean.cardano.client.account.Account
import com.bloxbean.cardano.client.api.model.Amount
import com.bloxbean.cardano.client.backend.api.BackendService
import com.bloxbean.cardano.client.backend.factory.BackendFactory
import com.bloxbean.cardano.client.backend.koios.KoiosBackendService
import com.bloxbean.cardano.client.function.helper.SignerProviders
import com.bloxbean.cardano.client.quicktx.QuickTxBuilder
import com.bloxbean.cardano.client.quicktx.Tx
import com.bloxbean.cardano.client.transaction.util.TransactionUtil
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.SessionScope
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.CardanoClient
import io.element.android.features.wallet.api.CardanoException
import io.element.android.features.wallet.api.PaymentRequest
import io.element.android.features.wallet.api.SignedTransaction
import io.element.android.features.wallet.api.TransactionBuilder
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.Arrays
import javax.inject.Inject
import java.math.BigInteger
/**
* Default implementation of [TransactionBuilder] using cardano-client-lib.
*
* ## UTXO Selection
* Uses largest-first coin selection strategy:
* 1. Sort UTXOs by amount descending
* 2. Select UTXOs until amount + fee is covered
* 3. Calculate change = total inputs - amount - fee
*
* ## Fee Calculation
* Fee is calculated using cardano-client-lib's QuickTxBuilder which
* uses protocol parameters to compute: fee = minFeeA * txSize + minFeeB
*
* ## Security
* - Signing keys are retrieved from storage (triggers biometric)
* - Key bytes are zeroed after use
* - Mnemonic is cleared from memory after key derivation
*/
@ContributesBinding(SessionScope::class)
class DefaultTransactionBuilder @Inject constructor(
@ -53,28 +39,22 @@ class DefaultTransactionBuilder @Inject constructor(
companion object {
private const val TAG = "TransactionBuilder"
/** Minimum ADA for a UTXO (Cardano protocol constraint) */
const val MIN_UTXO_LOVELACE = 1_000_000L // 1 ADA
/** Rough fee estimate for initial validation (actual fee calculated by library) */
const val MIN_UTXO_LOVELACE = 1_000_000L
private const val ROUGH_FEE_ESTIMATE = 200_000L
}
private val backendService: BackendService by lazy {
Timber.tag(TAG).d("Initializing Koios backend for tx building")
BackendFactory.getKoiosBackendService(CardanoNetworkConfig.KOIOS_BASE_URL)
KoiosBackendService(CardanoNetworkConfig.KOIOS_BASE_URL)
}
override suspend fun buildAndSign(request: PaymentRequest): Result<SignedTransaction> = withContext(Dispatchers.IO) {
Timber.tag(TAG).d("Building transaction: ${request.amountLovelace} lovelace to ${request.toAddress.take(20)}...")
runCatching {
// 1. Validate addresses
validateAddress(request.fromAddress, "sender")
validateAddress(request.toAddress, "recipient")
// 2. Validate amount (minimum 1 ADA)
if (request.amountLovelace < MIN_UTXO_LOVELACE) {
throw CardanoException.ApiException(
message = "Amount too small: minimum is 1 ADA (1,000,000 lovelace)",
@ -82,7 +62,6 @@ class DefaultTransactionBuilder @Inject constructor(
)
}
// 3. Fetch and validate UTXOs
val utxos = cardanoClient.getUtxos(request.fromAddress).getOrThrow()
if (utxos.isEmpty()) {
throw CardanoException.InsufficientFundsException(
@ -91,7 +70,6 @@ class DefaultTransactionBuilder @Inject constructor(
)
}
// 4. Calculate total available and do quick check
val totalAvailable = utxos.sumOf { it.amount }
val estimatedRequired = request.amountLovelace + ROUGH_FEE_ESTIMATE
@ -104,12 +82,10 @@ class DefaultTransactionBuilder @Inject constructor(
Timber.tag(TAG).d("UTXOs: ${utxos.size} totaling $totalAvailable lovelace")
// 5. Retrieve mnemonic (triggers biometric authentication via Android Keystore)
val mnemonicWords = keyStorage.getMnemonic(request.sessionId).getOrThrow()
val mnemonicString = mnemonicWords.joinToString(" ")
try {
// 6. Build and sign transaction
val signedTx = buildTransaction(
senderAddress = request.fromAddress,
recipientAddress = request.toAddress,
@ -120,100 +96,53 @@ class DefaultTransactionBuilder @Inject constructor(
Timber.tag(TAG).i("Transaction built: ${signedTx.txHash}, fee: ${signedTx.fee} lovelace")
signedTx
} finally {
// Best effort to clear mnemonic from memory
// Note: JVM String pooling makes this imperfect, but we try
Timber.tag(TAG).d("Transaction building complete")
}
}
}
/**
* Builds and signs a transaction using cardano-client-lib's QuickTx API.
*/
private fun buildTransaction(
senderAddress: String,
recipientAddress: String,
amountLovelace: Long,
mnemonic: String,
): SignedTransaction {
// Create Account from mnemonic (handles CIP-1852 derivation internally)
val account = Account(CardanoNetworkConfig.getNetwork(), mnemonic)
// Build transaction using QuickTx (high-level API)
val tx = Tx()
.payToAddress(recipientAddress, Amount.lovelace(amountLovelace))
.payToAddress(recipientAddress, Amount.lovelace(BigInteger.valueOf(amountLovelace)))
.from(senderAddress)
val quickTxBuilder = QuickTxBuilder(backendService)
// Build and sign
val result = quickTxBuilder
val signedTx = quickTxBuilder
.compose(tx)
.withSigner(SignerProviders.signerFrom(account))
.complete()
.buildAndSign()
if (!result.isSuccessful) {
val errorResponse = result.response ?: "Unknown error"
// Parse common error types
when {
errorResponse.contains("insufficient", ignoreCase = true) ||
errorResponse.contains("not enough", ignoreCase = true) -> {
throw CardanoException.InsufficientFundsException(
required = amountLovelace,
available = 0L // We don't know exact amount from error
)
}
errorResponse.contains("min", ignoreCase = true) &&
errorResponse.contains("utxo", ignoreCase = true) -> {
throw CardanoException.ApiException(
message = "Output too small: minimum UTXO value not met",
response = errorResponse
)
}
else -> {
throw CardanoException.ApiException(
message = "Transaction build failed: $errorResponse",
response = errorResponse
)
}
}
}
val signedTx = result.value
val txBytes = signedTx.serialize()
val txHash = signedTx.transactionId
val txHash = TransactionUtil.getTxHash(signedTx)
val txCbor = signedTx.serializeToHex()
val fee = signedTx.body.fee.toLong()
return SignedTransaction(
txCbor = txBytes.toHexString(),
txCbor = txCbor,
txHash = txHash,
fee = fee,
actualAmount = amountLovelace,
)
}
/**
* Validates a Cardano address format.
*/
private fun validateAddress(address: String, role: String) {
// Check prefix based on network
val expectedPrefix = CardanoNetworkConfig.ADDRESS_PREFIX
if (!address.startsWith(expectedPrefix)) {
throw CardanoException.InvalidAddressException(address)
}
// Basic length check (Cardano addresses are ~100+ chars)
if (address.length < 50) {
throw CardanoException.InvalidAddressException(address)
}
Timber.tag(TAG).d("$role address validated: ${address.take(20)}...")
}
/**
* Extension to convert ByteArray to hex string.
*/
private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }
}

View file

@ -7,34 +7,24 @@
package io.element.android.features.wallet.impl.cardano
import com.bloxbean.cardano.client.backend.api.BackendService
import com.bloxbean.cardano.client.backend.factory.BackendFactory
import com.bloxbean.cardano.client.backend.koios.KoiosBackendService
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.SessionScope
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.CardanoClient
import io.element.android.features.wallet.api.CardanoException
import io.element.android.features.wallet.api.ProtocolParameters
import io.element.android.features.wallet.api.TxStatus
import io.element.android.features.wallet.api.Utxo
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
/**
* Cardano blockchain client using the Koios public API.
*
* Koios is a decentralized API layer for Cardano that requires no API key.
* Rate limits: 100 requests per 10 seconds for anonymous users.
*
* Features:
* - Automatic retry with exponential backoff (3 attempts)
* - Rate limit handling with backoff
* - Network error recovery
*
* @see <a href="https://api.koios.rest/">Koios API Documentation</a>
*/
@ContributesBinding(SessionScope::class)
class KoiosCardanoClient @Inject constructor() : CardanoClient {
@ -43,17 +33,14 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
private const val MAX_RETRIES = 3
private const val INITIAL_BACKOFF_MS = 1000L
private const val MAX_BACKOFF_MS = 10000L
// Rate limiting: 100 req/10s = 1 req per 100ms minimum
private const val MIN_REQUEST_INTERVAL_MS = 100L
}
private val backendService: BackendService by lazy {
Timber.tag(TAG).d("Initializing Koios backend for ${CardanoNetworkConfig.NETWORK_NAME}")
BackendFactory.getKoiosBackendService(CardanoNetworkConfig.KOIOS_BASE_URL)
KoiosBackendService(CardanoNetworkConfig.KOIOS_BASE_URL)
}
// Simple rate limiting via mutex and timestamp tracking
private val rateLimitMutex = Mutex()
private var lastRequestTimeMs = 0L
@ -65,11 +52,10 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
val result = backendService.addressService.getAddressInfo(address)
if (result.isSuccessful) {
val info = result.value
// Find lovelace amount in the response
val lovelace = info.amount
?.find { it.unit == "lovelace" }
?.quantity
?.toLongOrNull()
?.toLong()
?: 0L
Result.success(lovelace)
} else {
@ -83,15 +69,13 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
withContext(Dispatchers.IO) {
throttleRequest()
// Fetch UTxOs with pagination (100 per page, page 1)
val result = backendService.utxoService.getUtxos(address, 100, 1)
if (result.isSuccessful) {
val utxos = result.value.map { utxo ->
// Extract lovelace amount from UTxO amounts
val lovelace = utxo.amount
?.find { it.unit == "lovelace" }
?.quantity
?.toLongOrNull()
?.toLong()
?: 0L
Utxo(
@ -113,7 +97,6 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
withContext(Dispatchers.IO) {
throttleRequest()
// Convert hex string to byte array
val txBytes = try {
signedTxCbor.hexToByteArray()
} catch (e: Exception) {
@ -148,14 +131,11 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
val result = backendService.transactionService.getTransaction(txHash)
if (result.isSuccessful) {
// If we got a response, the transaction is confirmed
Result.success(TxStatus.CONFIRMED)
} else {
// Check for 404 - transaction not found (pending or doesn't exist)
val response = result.response ?: ""
when {
response.contains("404") || response.contains("not found", ignoreCase = true) -> {
// Could be pending or never submitted
Result.success(TxStatus.PENDING)
}
else -> {
@ -179,7 +159,6 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
minFeeA = params.minFeeA?.toLong() ?: 44L,
minFeeB = params.minFeeB?.toLong() ?: 155381L,
maxTxSize = params.maxTxSize ?: 16384,
// coinsPerUtxoSize is the post-Babbage parameter (lovelace per byte)
utxoCostPerByte = params.coinsPerUtxoSize?.toLong() ?: 4310L,
)
)
@ -189,9 +168,6 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
}
}
/**
* Executes a request with retry logic and exponential backoff.
*/
private suspend fun <T> withRetry(
operation: String,
block: suspend () -> Result<T>,
@ -216,15 +192,12 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
val exception = result.exceptionOrNull() ?: Exception("Unknown error")
lastException = exception
// Check if error is retryable
val shouldRetry = when (exception) {
is CardanoException.RateLimitException -> {
// Use retry-after if provided, otherwise use backoff
backoffMs = exception.retryAfterMs ?: (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
true
}
is CardanoException.NetworkException -> {
// Retry on 5xx errors or network issues
exception.statusCode == null || exception.statusCode in 500..599
}
else -> false
@ -243,9 +216,6 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
return Result.failure(lastException ?: Exception("Max retries exceeded"))
}
/**
* Simple rate limiting - ensures minimum interval between requests.
*/
private suspend fun throttleRequest() {
rateLimitMutex.withLock {
val now = System.currentTimeMillis()
@ -257,9 +227,6 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
}
}
/**
* Parses error responses from Koios API into typed exceptions.
*/
private fun parseError(response: String?): CardanoException {
if (response == null) {
return CardanoException.NetworkException("No response from server")
@ -281,9 +248,6 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
}
}
/**
* Extension function to convert hex string to byte array.
*/
private fun String.hexToByteArray(): ByteArray {
require(length % 2 == 0) { "Hex string must have even length" }
return chunked(2)

View file

@ -16,7 +16,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import timber.log.Timber
import javax.inject.Inject
import dev.zacsweers.metro.Inject
/**
* Default implementation of [PaymentStatusPoller].

View file

@ -10,7 +10,7 @@ import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.AppScope
import dev.zacsweers.metro.AppScope
import kotlinx.serialization.json.Json
/**

View file

@ -7,27 +7,28 @@
package io.element.android.features.wallet.impl.payment
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.SessionScope
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.PaymentCardStatus
import io.element.android.features.wallet.api.PaymentEventSender
import io.element.android.features.wallet.api.PaymentRequest
import io.element.android.features.wallet.api.SignedTransaction
import io.element.android.features.wallet.api.timeline.TimelineItemPaymentContent
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject
/**
* Default implementation of [PaymentEventSender].
*
* Sends payment events as custom Matrix events using the raw event API.
* Since the Matrix SDK does not expose raw event sending, we send payment data
* as a structured message with a recognizable prefix that can be parsed by the UI.
*
* Event type: co.sulkta.payment.request
* Event content: JSON-serialized [PaymentEventData]
* Message format: $CARDANO_PAY${json}
* This allows the timeline UI to render a payment card instead of raw text.
*/
@ContributesBinding(SessionScope::class)
class DefaultPaymentEventSender @Inject constructor() : PaymentEventSender {
private val json = Json {
encodeDefaults = true
ignoreUnknownKeys = true
@ -48,12 +49,17 @@ class DefaultPaymentEventSender @Inject constructor() : PaymentEventSender {
network = network,
)
val content = json.encodeToString(paymentData)
val jsonContent = json.encodeToString(paymentData)
val message = "$PAYMENT_MESSAGE_PREFIX$jsonContent"
return timeline.sendRaw(
eventType = PAYMENT_EVENT_TYPE,
content = content,
)
// Send as a regular message - the timeline renderer will recognize the prefix
return runCatching {
timeline.sendMessage(
body = message,
htmlBody = null,
intentionalMentions = emptyList(),
)
}
}
override suspend fun sendStatusUpdate(
@ -68,25 +74,26 @@ class DefaultPaymentEventSender @Inject constructor() : PaymentEventSender {
network = network,
)
val content = json.encodeToString(statusData)
val jsonContent = json.encodeToString(statusData)
val message = "$STATUS_MESSAGE_PREFIX$jsonContent"
return timeline.sendRaw(
eventType = STATUS_UPDATE_EVENT_TYPE,
content = content,
)
return runCatching {
timeline.sendMessage(
body = message,
htmlBody = null,
intentionalMentions = emptyList(),
)
}
}
companion object {
/** Custom event type for Cardano payment requests (reverse-domain format) */
const val PAYMENT_EVENT_TYPE = "co.sulkta.payment.request"
/** Custom event type for payment status updates */
const val STATUS_UPDATE_EVENT_TYPE = "co.sulkta.payment.status"
/** Prefix for payment messages - UI parses this to render payment cards */
const val PAYMENT_MESSAGE_PREFIX = "\$CARDANO_PAY$"
/** Prefix for status update messages */
const val STATUS_MESSAGE_PREFIX = "\$CARDANO_STATUS$"
}
}
/**
* JSON-serializable payment event data.
*/
@kotlinx.serialization.Serializable
data class PaymentEventData(
val amountLovelace: Long,
@ -97,9 +104,6 @@ data class PaymentEventData(
val network: String,
)
/**
* JSON-serializable payment status update data.
*/
@kotlinx.serialization.Serializable
data class PaymentStatusUpdateData(
val txHash: String,

View file

@ -8,7 +8,9 @@ package io.element.android.features.wallet.impl.payment
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentActivity
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -23,14 +25,8 @@ import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
/**
* Node for the payment confirmation screen.
*
* Handles biometric authentication before proceeding to payment submission.
*/
@ContributesNode(SessionScope::class)
@AssistedInject
class PaymentConfirmationNode(
class PaymentConfirmationNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenterFactory: PaymentConfirmationPresenter.Factory,
@ -61,15 +57,15 @@ class PaymentConfirmationNode(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
PaymentConfirmationView(
state = state,
onConfirm = {
// Trigger biometric authentication
lifecycleScope.launch {
val activity = requireActivity() as? FragmentActivity
coroutineScope.launch {
val activity = context as? FragmentActivity
if (activity == null) {
// Fallback: proceed without biometric (should not happen)
callback.onConfirmed()
return@launch
}
@ -85,11 +81,10 @@ class PaymentConfirmationNode(
callback.onConfirmed()
}
is BiometricAuthenticator.AuthResult.Error -> {
// Authentication failed - stay on screen
// Could show a snackbar here
// Stay on screen
}
BiometricAuthenticator.AuthResult.Cancelled -> {
// User cancelled - stay on screen
// Stay on screen
}
}
}

View file

@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.IconSource
/**
* Payment confirmation screen.
@ -98,7 +99,7 @@ fun PaymentConfirmationView(
onClick = { state.eventSink(PaymentFlowEvents.ConfirmPayment); onConfirm() },
enabled = !state.isFeeLoading && !state.insufficientFunds,
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
leadingIcon = { Icon(Icons.Default.Send, contentDescription = null) },
leadingIcon = IconSource.Vector(Icons.Default.Send),
)
}
}

View file

@ -7,104 +7,27 @@
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 io.element.android.libraries.di.AppScope
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import javax.inject.Inject
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 {
@ -123,7 +46,7 @@ class DefaultSeedPhraseManager @Inject constructor() : SeedPhraseManager {
private val mnemonicCode = MnemonicCode()
private val wordList: List<String> by lazy {
Words.ENGLISH.words.toList()
mnemonicCode.wordList
}
override fun generateSeedPhrase(): List<String> {
@ -145,7 +68,6 @@ class DefaultSeedPhraseManager @Inject constructor() : SeedPhraseManager {
val words = try {
mnemonicCode.toMnemonic(entropy)
} finally {
// Clear entropy immediately
entropy.fill(0)
}
@ -154,14 +76,12 @@ class DefaultSeedPhraseManager @Inject constructor() : SeedPhraseManager {
}
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(
@ -169,7 +89,6 @@ class DefaultSeedPhraseManager @Inject constructor() : SeedPhraseManager {
)
}
// Validate checksum
return try {
mnemonicCode.check(words.map { it.lowercase() })
SeedPhraseValidationResult.Valid(words.size)

View file

@ -7,7 +7,7 @@
package io.element.android.features.wallet.impl.slash
import io.element.android.libraries.matrix.api.core.UserId
import javax.inject.Inject
import dev.zacsweers.metro.Inject
import java.math.BigDecimal
/**

View file

@ -14,8 +14,9 @@ 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 io.element.android.libraries.di.AppScope
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
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
@ -30,24 +31,7 @@ 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,
@ -61,10 +45,8 @@ class CardanoKeyStorageImpl @Inject constructor(
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 const val MNEMONIC_ENTROPY_BYTES = 32
}
private val keyStore: KeyStore by lazy {
@ -89,21 +71,16 @@ class CardanoKeyStorageImpl @Inject constructor(
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.getNetwork(), mnemonicString)
@ -125,12 +102,10 @@ class CardanoKeyStorageImpl @Inject constructor(
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)
@ -138,7 +113,6 @@ class CardanoKeyStorageImpl @Inject constructor(
throw IllegalArgumentException("Invalid mnemonic: ${e.message}")
}
// Verify it produces valid Cardano addresses
val mnemonicString = mnemonic.joinToString(" ")
val account = try {
Account(CardanoNetworkConfig.getNetwork(), mnemonicString)
@ -146,7 +120,6 @@ class CardanoKeyStorageImpl @Inject constructor(
throw IllegalArgumentException("Failed to derive Cardano keys: ${e.message}")
}
// Store encrypted mnemonic
storeMnemonic(sessionId, mnemonic)
Timber.i("Imported Cardano wallet for session: ${sessionId.value}")
@ -186,13 +159,11 @@ class CardanoKeyStorageImpl @Inject constructor(
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)
@ -202,19 +173,14 @@ class CardanoKeyStorageImpl @Inject constructor(
}
}
/**
* 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,
@ -223,11 +189,8 @@ class CardanoKeyStorageImpl @Inject constructor(
.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()
@ -235,36 +198,24 @@ class CardanoKeyStorageImpl @Inject constructor(
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)
@ -280,12 +231,10 @@ class CardanoKeyStorageImpl @Inject constructor(
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)
@ -293,16 +242,11 @@ class CardanoKeyStorageImpl @Inject constructor(
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("@", "")

View file

@ -6,7 +6,7 @@
package io.element.android.features.wallet.impl.timeline
import javax.inject.Inject
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.PaymentCardStatus
import io.element.android.features.wallet.api.timeline.TimelineItemPaymentContent
import io.element.android.features.wallet.impl.payment.DefaultPaymentEventSender
@ -15,14 +15,13 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.longOrNull
import timber.log.Timber
/**
* Factory for creating [TimelineItemPaymentContent] from raw payment events.
* Factory for creating [TimelineItemPaymentContent] from message content.
*
* Parses custom events with type "co.sulkta.payment.request" and extracts the payment data.
* Parses messages with the $CARDANO_PAY$ prefix and extracts payment data.
*/
@Inject
class TimelineItemContentPaymentFactory {
@ -32,32 +31,64 @@ class TimelineItemContentPaymentFactory {
}
/**
* Check if an event type is a payment event.
* Check if a message is a payment message.
*/
fun isPaymentEventType(eventType: String): Boolean {
return eventType == DefaultPaymentEventSender.PAYMENT_EVENT_TYPE
fun isPaymentMessage(body: String): Boolean {
return body.startsWith(DefaultPaymentEventSender.PAYMENT_MESSAGE_PREFIX)
}
/**
* Check if an event type is a payment status update.
* Check if a message is a status update message.
*/
fun isStatusUpdateEventType(eventType: String): Boolean {
return eventType == DefaultPaymentEventSender.STATUS_UPDATE_EVENT_TYPE
fun isStatusUpdateMessage(body: String): Boolean {
return body.startsWith(DefaultPaymentEventSender.STATUS_MESSAGE_PREFIX)
}
/**
* Create a [TimelineItemPaymentContent] from raw JSON event content.
* Create a [TimelineItemPaymentContent] from a message body.
*
* @param rawJson The raw JSON content from the Matrix event
* @param isSentByMe Whether the current user sent this event
* @param body The message body
* @param isSentByMe Whether the current user sent this message
* @return The parsed payment content, or null if parsing failed
*/
fun createFromMessage(body: String, isSentByMe: Boolean): TimelineItemPaymentContent? {
return try {
val jsonContent = when {
body.startsWith(DefaultPaymentEventSender.PAYMENT_MESSAGE_PREFIX) -> {
body.removePrefix(DefaultPaymentEventSender.PAYMENT_MESSAGE_PREFIX)
}
body.startsWith(DefaultPaymentEventSender.STATUS_MESSAGE_PREFIX) -> {
// Status updates don't create full payment content
return null
}
else -> return null
}
val data = json.decodeFromString<PaymentEventData>(jsonContent)
TimelineItemPaymentContent(
amountLovelace = data.amountLovelace,
toAddress = data.toAddress,
fromAddress = data.fromAddress,
txHash = data.txHash,
status = parseStatus(data.status),
network = data.network,
isSentByMe = isSentByMe,
fallbackText = "💰 ${TimelineItemPaymentContent.formatAda(data.amountLovelace)}",
)
} catch (e: Exception) {
Timber.w(e, "Failed to parse payment message")
null
}
}
/**
* Create a [TimelineItemPaymentContent] from raw JSON event content (legacy support).
*/
fun createFromRaw(rawJson: String, isSentByMe: Boolean): TimelineItemPaymentContent? {
return try {
// Try to parse the content field from the raw event JSON
val eventJson = json.parseToJsonElement(rawJson).jsonObject
val content = eventJson["content"]?.jsonObject ?: eventJson
val data = parsePaymentData(content)
if (data != null) {
TimelineItemPaymentContent(
@ -103,21 +134,21 @@ class TimelineItemContentPaymentFactory {
val amountLovelace = content["amount_lovelace"]?.jsonPrimitive?.longOrNull
?: content["amountLovelace"]?.jsonPrimitive?.longOrNull
?: return null
val toAddress = content["to_address"]?.jsonPrimitive?.content
?: content["toAddress"]?.jsonPrimitive?.content
?: return null
val fromAddress = content["from_address"]?.jsonPrimitive?.content
?: content["fromAddress"]?.jsonPrimitive?.content
?: return null
val txHash = content["tx_hash"]?.jsonPrimitive?.content
?: content["txHash"]?.jsonPrimitive?.content
val status = content["status"]?.jsonPrimitive?.content ?: "pending"
val network = content["network"]?.jsonPrimitive?.content ?: "mainnet"
PaymentEventData(
amountLovelace = amountLovelace,
toAddress = toAddress,
@ -140,11 +171,4 @@ class TimelineItemContentPaymentFactory {
else -> PaymentCardStatus.PENDING
}
}
companion object {
/** Custom event type for Cardano payment requests */
const val PAYMENT_EVENT_TYPE = DefaultPaymentEventSender.PAYMENT_EVENT_TYPE
/** Custom event type for payment status updates */
const val STATUS_UPDATE_EVENT_TYPE = DefaultPaymentEventSender.STATUS_UPDATE_EVENT_TYPE
}
}

View file

@ -14,6 +14,8 @@ android {
dependencies {
api(projects.features.wallet.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.architecture)
implementation(projects.tests.testutils)
implementation(libs.coroutines.core)
}

View file

@ -11,22 +11,22 @@ import com.bumble.appyx.core.node.Node
import io.element.android.features.wallet.api.WalletEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.lambda.lambdaError
class FakeWalletEntryPoint : WalletEntryPoint {
class Builder : WalletEntryPoint.Builder {
override fun setRoomId(roomId: RoomId): Builder = this
override fun setRecipientUserId(userId: UserId?): Builder = this
override fun setRecipientAddress(address: String?): Builder = this
override fun setAmount(amount: String?): Builder = this
override fun build(): Node = lambdaError()
override fun setRoomId(roomId: RoomId): WalletEntryPoint.Builder = this
override fun setRecipientUserId(userId: UserId?): WalletEntryPoint.Builder = this
override fun setRecipientAddress(address: String?): WalletEntryPoint.Builder = this
override fun setAmount(amount: String?): WalletEntryPoint.Builder = this
override fun build(): Node {
throw NotImplementedError("FakeWalletEntryPoint cannot build a real node")
}
}
override fun paymentFlowBuilder(
parentNode: Node,
buildContext: BuildContext,
callback: WalletEntryPoint.Callback,
): WalletEntryPoint.Builder {
return Builder()
}
): WalletEntryPoint.Builder = Builder()
}

View file

@ -12,132 +12,99 @@ 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, WalletData>()
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 generateWalletResult: Result<WalletCreationResult> = Result.success(
WalletCreationResult(
mnemonic = List(24) { "word$it" },
baseAddress = "addr_test1qpfake",
stakeAddress = "stake_test1upfake",
)
)
var importWalletResult: Result<String> = Result.success("addr_test1qpimported")
var getMnemonicResult: Result<List<String>>? = null
var getBaseAddressResult: Result<String>? = null
var getStakeAddressResult: Result<String>? = null
var deleteWalletResult: Result<Unit> = Result.success(Unit)
private data class WalletData(
val mnemonic: List<String>,
val baseAddress: String,
val stakeAddress: String,
)
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,
return generateWalletResult.onSuccess { result ->
if (wallets.containsKey(sessionId.value)) {
return Result.failure(IllegalStateException("Wallet already exists"))
}
wallets[sessionId.value] = WalletData(
mnemonic = result.mnemonic,
baseAddress = result.baseAddress,
stakeAddress = result.stakeAddress,
)
)
}
}
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"))
return importWalletResult.onSuccess { address ->
if (wallets.containsKey(sessionId.value)) {
return Result.failure(IllegalStateException("Wallet already exists"))
}
wallets[sessionId.value] = WalletData(
mnemonic = mnemonic,
baseAddress = address,
stakeAddress = "stake_test1upimported",
)
}
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) }
getMnemonicResult?.let { return it }
val wallet = wallets[sessionId.value]
?: return Result.failure(IllegalStateException("No wallet found for session"))
?: return Result.failure(IllegalStateException("No wallet"))
return Result.success(wallet.mnemonic)
}
override suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int): Result<String> {
getAddressError?.let { return Result.failure(it) }
getBaseAddressResult?.let { return 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)
?: return Result.failure(IllegalStateException("No wallet"))
return Result.success(wallet.baseAddress)
}
override suspend fun getStakeAddress(sessionId: SessionId): Result<String> {
getAddressError?.let { return Result.failure(it) }
getStakeAddressResult?.let { return it }
val wallet = wallets[sessionId.value]
?: return Result.failure(IllegalStateException("No wallet found for session"))
?: return Result.failure(IllegalStateException("No wallet"))
return Result.success(wallet.stakeAddress)
}
override suspend fun deleteWallet(sessionId: SessionId): Result<Unit> {
wallets.remove(sessionId.value)
return Result.success(Unit)
return deleteWalletResult
}
/**
* Clears all stored wallets. Use in test teardown.
*/
fun clear() {
fun reset() {
wallets.clear()
generateWalletError = null
importWalletError = null
getMnemonicError = null
getAddressError = null
generateWalletResult = Result.success(
WalletCreationResult(
mnemonic = List(24) { "word$it" },
baseAddress = "addr_test1qpfake",
stakeAddress = "stake_test1upfake",
)
)
importWalletResult = Result.success("addr_test1qpimported")
getMnemonicResult = null
getBaseAddressResult = null
getStakeAddressResult = null
deleteWalletResult = Result.success(Unit)
}
/**
* 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,
)
}