element-x-ada/PHASE1-PLAN.md

108 KiB

Element X ADA Wallet — Phase 1 Implementation Plan

Date: 2026-03-27
Author: Kayos
Target: Local-only MVP — /pay 10 ADA @jacob end-to-end
Repo: Sulkta-Coop/element-x-ada


Overview

Phase 1 delivers a functional Cardano lite wallet embedded in Element X Android:

  • User types /pay 10 ADA @jacob in a DM
  • Confirmation screen opens with amount + recipient
  • Biometric authentication triggers
  • Transaction is signed and submitted via Blockfrost
  • Payment card renders in timeline for both parties
  • Recipient taps to view tx on CardanoScan

Constraints:

  • Local-only (no SSSS sync — Phase 2)
  • Keys stored in Android Keystore
  • Blockfrost for chain queries
  • cardano-client-lib (pure Java, no JNI)
  • Custom Matrix event: m.payment.cardano

Dependency Graph

┌─────────────────────────────────────────────────────────────────────┐
│                         BUILD ORDER                                  │
└─────────────────────────────────────────────────────────────────────┘

Task 1: Module Scaffolding
    │
    ├──────────────────┬──────────────────┐
    ▼                  ▼                  ▼
Task 2: Key Storage   Task 3: Blockfrost  Task 8: SDK Extension
    │                  │                       │
    └────────┬─────────┘                       │
             ▼                                 │
        Task 4: Transaction Builder            │
             │                                 │
             ├─────────────────────────────────┘
             ▼
        Task 5: Slash Command Parser
             │
             ▼
        Task 6: Payment Flow UI
             │
             ▼
        Task 7: Payment Card Timeline

Task 1: features/wallet/ Module Scaffolding

Blocks: Tasks 2, 3, 4, 5, 6, 7
Blocked by: Nothing
Effort: 1 day

Acceptance criteria:

  • Module compiles with ./gradlew :features:wallet:impl:assemble
  • DI module loads without errors
  • WalletEntryPoint accessible from app module
  • Unit test infrastructure works (./gradlew :features:wallet:impl:test)

Files

New: features/wallet/api/build.gradle.kts

plugins {
    id("io.element.android-library")
}

android {
    namespace = "io.element.android.features.wallet.api"
}

dependencies {
    implementation(projects.libraries.architecture)
    implementation(projects.libraries.matrix.api)
    implementation(projects.libraries.designsystem)
}

New: features/wallet/impl/build.gradle.kts

plugins {
    id("io.element.android-compose-library")
    alias(libs.plugins.anvil)
    alias(libs.plugins.kotlin.serialization)
}

android {
    namespace = "io.element.android.features.wallet.impl"
}

anvil {
    generateDaggerFactories.set(true)
}

dependencies {
    implementation(projects.features.wallet.api)
    implementation(projects.libraries.architecture)
    implementation(projects.libraries.matrix.api)
    implementation(projects.libraries.matrix.impl)
    implementation(projects.libraries.designsystem)
    implementation(projects.libraries.cryptography.api)
    implementation(projects.libraries.cryptography.impl)
    implementation(projects.libraries.core)
    implementation(projects.libraries.uiStrings)
    
    // Cardano
    implementation("com.bloxbean.cardano:cardano-client-lib:0.7.1")
    implementation("com.bloxbean.cardano:cardano-client-backend-blockfrost:0.7.1")
    implementation("com.bloxbean.cardano:cardano-client-crypto:0.7.1")
    
    // Biometric
    implementation(libs.androidx.biometric)
    
    // JSON
    implementation(libs.serialization.json)
    
    // Coroutines
    implementation(libs.coroutines.core)
    
    testImplementation(projects.features.wallet.test)
    testImplementation(libs.test.junit)
    testImplementation(libs.test.truth)
    testImplementation(libs.coroutines.test)
}

New: features/wallet/test/build.gradle.kts

plugins {
    id("io.element.android-library")
}

android {
    namespace = "io.element.android.features.wallet.test"
}

dependencies {
    api(projects.features.wallet.api)
    implementation(libs.coroutines.core)
}

New: features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletEntryPoint.kt

package io.element.android.features.wallet.api

import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node

interface WalletEntryPoint {
    fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
    
    interface NodeBuilder {
        fun params(params: Params): NodeBuilder
        fun build(): Node
    }
    
    data class Params(
        val roomId: String,
        val recipientUserId: String?,
        val recipientAddress: String?,
        val amount: String?,
    )
}

New: features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletState.kt

package io.element.android.features.wallet.api

data class WalletState(
    val hasWallet: Boolean,
    val address: String?,
    val balanceLovelace: Long?,
    val balanceAda: String?,
    val isLoading: Boolean,
    val error: String?,
)

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/di/WalletModule.kt

package io.element.android.features.wallet.impl.di

import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import dagger.Provides
import io.element.android.features.wallet.api.WalletEntryPoint
import io.element.android.features.wallet.impl.DefaultWalletEntryPoint
import io.element.android.features.wallet.impl.cardano.BlockfrostClient
import io.element.android.features.wallet.impl.cardano.BlockfrostClientImpl
import io.element.android.features.wallet.impl.storage.CardanoKeyStorage
import io.element.android.features.wallet.impl.storage.CardanoKeyStorageImpl
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import javax.inject.Named

@Module
@ContributesTo(AppScope::class)
interface WalletModule {
    @Binds
    fun bindWalletEntryPoint(impl: DefaultWalletEntryPoint): WalletEntryPoint
    
    @Binds
    fun bindCardanoKeyStorage(impl: CardanoKeyStorageImpl): CardanoKeyStorage
    
    @Binds
    fun bindBlockfrostClient(impl: BlockfrostClientImpl): BlockfrostClient
    
    companion object {
        @Provides
        @Named("blockfrost_project_id")
        fun provideBlockfrostProjectId(): String {
            // TODO: Move to BuildConfig or encrypted storage
            return BuildConfig.BLOCKFROST_PROJECT_ID
        }
    }
}

Modify: settings.gradle.kts (root)

Add to features section:

include(":features:wallet:api")
include(":features:wallet:impl")
include(":features:wallet:test")

Modify: app/build.gradle.kts

Add dependency:

implementation(projects.features.wallet.impl)

New: gradle.properties addition

BLOCKFROST_PROJECT_ID=mainnetXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Key Implementation Details

  1. Module structure follows Element X pattern: api/impl/test separation
  2. Anvil for DI: Use @ContributesBinding and @ContributesTo annotations
  3. AppScope: Wallet services are app-scoped (singleton per app lifecycle)
  4. SessionScope consideration: If wallet-per-account is needed later, migrate to SessionScope

Gotchas

  • ProGuard rules: cardano-client-lib uses reflection for CBOR. Add to proguard-rules.pro:
    -keep class com.bloxbean.cardano.** { *; }
    -keepclassmembers class * {
        @com.fasterxml.jackson.annotation.* *;
    }
    
  • Multidex: The Cardano library is large. Ensure multidex is enabled (it should be already).
  • minSdk: cardano-client-lib requires API 21+. Element X is API 23+, so we're fine.

Task 2: Key Generation + Storage (CardanoKeyStorage)

Blocks: Task 4 (Transaction Builder)
Blocked by: Task 1
Effort: 3 days

Acceptance criteria:

  • New wallet generates valid 24-word BIP-39 mnemonic
  • Mnemonic encrypts with Android Keystore key
  • Mnemonic decrypts correctly after biometric auth
  • Derived addresses match cardano-client-lib reference
  • Seed phrase backup screen shows words with FLAG_SECURE
  • Wallet deletion clears all key material
  • Unit tests pass for key derivation paths

Files

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/storage/CardanoKeyStorage.kt

package io.element.android.features.wallet.impl.storage

import io.element.android.libraries.matrix.api.core.SessionId

interface CardanoKeyStorage {
    suspend fun hasWallet(sessionId: SessionId): Boolean
    suspend fun createWallet(sessionId: SessionId): Result<WalletCreationResult>
    suspend fun importWallet(sessionId: SessionId, mnemonic: String): Result<Unit>
    suspend fun getMnemonic(sessionId: SessionId): Result<String>
    suspend fun getSpendingKey(sessionId: SessionId, addressIndex: Int = 0): Result<ByteArray>
    suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int = 0): Result<String>
    suspend fun getStakeAddress(sessionId: SessionId): Result<String>
    suspend fun deleteWallet(sessionId: SessionId): Result<Unit>
}

data class WalletCreationResult(
    val mnemonic: String,
    val baseAddress: String,
    val stakeAddress: String,
)

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/storage/CardanoKeyStorageImpl.kt

package io.element.android.features.wallet.impl.storage

import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.bloxbean.cardano.client.account.Account
import com.bloxbean.cardano.client.common.model.Networks
import com.bloxbean.cardano.client.crypto.bip39.MnemonicCode
import com.bloxbean.cardano.client.crypto.bip39.Words
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.cryptography.api.SecretKeyRepository
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject

@ContributesBinding(AppScope::class)
class CardanoKeyStorageImpl @Inject constructor(
    @ApplicationContext private val context: Context,
    private val secretKeyRepository: SecretKeyRepository,
) : CardanoKeyStorage {

    companion object {
        private const val PREFS_NAME = "cardano_wallet_prefs"
        private const val KEY_ENCRYPTED_MNEMONIC = "encrypted_mnemonic_"
        private const val KEY_IV = "iv_"
        private const val KEYSTORE_ALIAS_PREFIX = "cardano_wallet_"
        private const val GCM_TAG_LENGTH = 128
        private const val GCM_IV_LENGTH = 12
    }

    private val masterKey by lazy {
        MasterKey.Builder(context)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
            .setUserAuthenticationRequired(true)
            .setUserAuthenticationParameters(
                30, // validity duration in seconds
                MasterKey.AUTH_BIOMETRIC_STRONG or MasterKey.AUTH_DEVICE_CREDENTIAL
            )
            .build()
    }

    private fun getPrefs() = EncryptedSharedPreferences.create(
        context,
        PREFS_NAME,
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    override suspend fun hasWallet(sessionId: SessionId): Boolean = withContext(Dispatchers.IO) {
        val prefs = getPrefs()
        prefs.contains(KEY_ENCRYPTED_MNEMONIC + sessionId.value)
    }

    override suspend fun createWallet(sessionId: SessionId): Result<WalletCreationResult> = 
        withContext(Dispatchers.IO) {
            runCatching {
                // Generate 24-word mnemonic (256 bits entropy)
                val mnemonicCode = MnemonicCode()
                val entropy = ByteArray(32).also { SecureRandom().nextBytes(it) }
                val mnemonic = mnemonicCode.toMnemonic(entropy).joinToString(" ")
                
                // Store encrypted
                storeMnemonic(sessionId, mnemonic)
                
                // Derive addresses
                val account = Account(Networks.mainnet(), mnemonic)
                
                WalletCreationResult(
                    mnemonic = mnemonic,
                    baseAddress = account.baseAddress(),
                    stakeAddress = account.stakeAddress(),
                )
            }
        }

    override suspend fun importWallet(sessionId: SessionId, mnemonic: String): Result<Unit> =
        withContext(Dispatchers.IO) {
            runCatching {
                // Validate mnemonic
                val words = mnemonic.trim().split("\\s+".toRegex())
                require(words.size in listOf(12, 15, 18, 21, 24)) {
                    "Invalid mnemonic length: ${words.size} words"
                }
                
                // Verify it's valid BIP-39
                val mnemonicCode = MnemonicCode()
                mnemonicCode.check(words)
                
                // Verify it derives valid Cardano addresses
                Account(Networks.mainnet(), mnemonic)
                
                // Store encrypted
                storeMnemonic(sessionId, mnemonic)
            }
        }

    override suspend fun getMnemonic(sessionId: SessionId): Result<String> =
        withContext(Dispatchers.IO) {
            runCatching {
                retrieveMnemonic(sessionId)
            }
        }

    override suspend fun getSpendingKey(sessionId: SessionId, addressIndex: Int): Result<ByteArray> =
        withContext(Dispatchers.IO) {
            runCatching {
                val mnemonic = retrieveMnemonic(sessionId)
                val account = Account(Networks.mainnet(), mnemonic, addressIndex)
                account.privateKeyBytes()
            }
        }

    override suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int): Result<String> =
        withContext(Dispatchers.IO) {
            runCatching {
                val mnemonic = retrieveMnemonic(sessionId)
                val account = Account(Networks.mainnet(), mnemonic, addressIndex)
                account.baseAddress()
            }
        }

    override suspend fun getStakeAddress(sessionId: SessionId): Result<String> =
        withContext(Dispatchers.IO) {
            runCatching {
                val mnemonic = retrieveMnemonic(sessionId)
                val account = Account(Networks.mainnet(), mnemonic)
                account.stakeAddress()
            }
        }

    override suspend fun deleteWallet(sessionId: SessionId): Result<Unit> =
        withContext(Dispatchers.IO) {
            runCatching {
                val prefs = getPrefs()
                prefs.edit()
                    .remove(KEY_ENCRYPTED_MNEMONIC + sessionId.value)
                    .remove(KEY_IV + sessionId.value)
                    .apply()
                
                // Delete keystore key
                secretKeyRepository.deleteKey(KEYSTORE_ALIAS_PREFIX + sessionId.value)
            }
        }

    private fun storeMnemonic(sessionId: SessionId, mnemonic: String) {
        val keyAlias = KEYSTORE_ALIAS_PREFIX + sessionId.value
        val secretKey = secretKeyRepository.getOrCreateKey(
            alias = keyAlias,
            requiresUserAuthentication = true
        )
        
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        cipher.init(Cipher.ENCRYPT_MODE, secretKey)
        
        val iv = cipher.iv
        val encrypted = cipher.doFinal(mnemonic.toByteArray(Charsets.UTF_8))
        
        val prefs = getPrefs()
        prefs.edit()
            .putString(KEY_ENCRYPTED_MNEMONIC + sessionId.value, 
                       android.util.Base64.encodeToString(encrypted, android.util.Base64.NO_WRAP))
            .putString(KEY_IV + sessionId.value,
                       android.util.Base64.encodeToString(iv, android.util.Base64.NO_WRAP))
            .apply()
    }

    private fun retrieveMnemonic(sessionId: SessionId): String {
        val prefs = getPrefs()
        val encryptedB64 = prefs.getString(KEY_ENCRYPTED_MNEMONIC + sessionId.value, null)
            ?: throw IllegalStateException("No wallet found for session")
        val ivB64 = prefs.getString(KEY_IV + sessionId.value, null)
            ?: throw IllegalStateException("No IV found for session")
        
        val encrypted = android.util.Base64.decode(encryptedB64, android.util.Base64.NO_WRAP)
        val iv = android.util.Base64.decode(ivB64, android.util.Base64.NO_WRAP)
        
        val keyAlias = KEYSTORE_ALIAS_PREFIX + sessionId.value
        val secretKey = secretKeyRepository.getOrCreateKey(
            alias = keyAlias,
            requiresUserAuthentication = true
        )
        
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
        cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
        
        return String(cipher.doFinal(encrypted), Charsets.UTF_8)
    }
}

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/SeedPhraseBackupScreen.kt

package io.element.android.features.wallet.impl.setup

import android.view.WindowManager
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
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.Text

@Composable
fun SeedPhraseBackupScreen(
    words: List<String>,
    onConfirm: () -> Unit,
    onBack: () -> Unit,
    modifier: Modifier = Modifier,
) {
    // FLAG_SECURE to prevent screenshots
    val view = LocalView.current
    DisposableEffect(Unit) {
        val window = (view.context as? android.app.Activity)?.window
        window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
        onDispose {
            window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
        }
    }
    
    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Text(
            text = "Your Recovery Phrase",
            style = MaterialTheme.typography.headlineMedium,
        )
        
        Spacer(modifier = Modifier.height(8.dp))
        
        Text(
            text = "Write down these 24 words in order. Never share them with anyone.",
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.error,
        )
        
        Spacer(modifier = Modifier.height(24.dp))
        
        LazyVerticalGrid(
            columns = GridCells.Fixed(3),
            verticalArrangement = Arrangement.spacedBy(8.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            modifier = Modifier.weight(1f),
        ) {
            itemsIndexed(words) { index, word ->
                SeedWordItem(index = index + 1, word = word)
            }
        }
        
        Spacer(modifier = Modifier.height(24.dp))
        
        Button(
            text = "I've written it down",
            onClick = onConfirm,
            modifier = Modifier.fillMaxWidth(),
        )
        
        Spacer(modifier = Modifier.height(8.dp))
        
        TextButton(onClick = onBack) {
            Text("Go back")
        }
    }
}

@Composable
private fun SeedWordItem(
    index: Int,
    word: String,
    modifier: Modifier = Modifier,
) {
    Surface(
        modifier = modifier,
        shape = MaterialTheme.shapes.small,
        color = MaterialTheme.colorScheme.surfaceVariant,
    ) {
        Row(
            modifier = Modifier.padding(8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Text(
                text = "$index.",
                style = MaterialTheme.typography.labelSmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
            Spacer(modifier = Modifier.width(4.dp))
            Text(
                text = word,
                style = MaterialTheme.typography.bodyMedium,
            )
        }
    }
}

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/biometric/BiometricAuthenticator.kt

package io.element.android.features.wallet.impl.biometric

import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import kotlin.coroutines.resume

class BiometricAuthenticator @Inject constructor() {
    
    sealed class AuthResult {
        object Success : AuthResult()
        data class Error(val code: Int, val message: String) : AuthResult()
        object Cancelled : AuthResult()
    }
    
    fun canAuthenticate(context: Context): Boolean {
        val biometricManager = BiometricManager.from(context)
        return biometricManager.canAuthenticate(
            BiometricManager.Authenticators.BIOMETRIC_STRONG or
            BiometricManager.Authenticators.DEVICE_CREDENTIAL
        ) == BiometricManager.BIOMETRIC_SUCCESS
    }
    
    suspend fun authenticate(
        activity: FragmentActivity,
        title: String = "Authenticate",
        subtitle: String = "Confirm your identity to continue",
        negativeButtonText: String = "Cancel",
    ): AuthResult = suspendCancellableCoroutine { continuation ->
        val executor = ContextCompat.getMainExecutor(activity)
        
        val callback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                if (continuation.isActive) {
                    continuation.resume(AuthResult.Success)
                }
            }
            
            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                if (continuation.isActive) {
                    if (errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
                        errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
                        errorCode == BiometricPrompt.ERROR_CANCELED) {
                        continuation.resume(AuthResult.Cancelled)
                    } else {
                        continuation.resume(AuthResult.Error(errorCode, errString.toString()))
                    }
                }
            }
            
            override fun onAuthenticationFailed() {
                // Don't resume yet - user can retry
            }
        }
        
        val biometricPrompt = BiometricPrompt(activity, executor, callback)
        
        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle(title)
            .setSubtitle(subtitle)
            .setAllowedAuthenticators(
                BiometricManager.Authenticators.BIOMETRIC_STRONG or
                BiometricManager.Authenticators.DEVICE_CREDENTIAL
            )
            .build()
        
        biometricPrompt.authenticate(promptInfo)
        
        continuation.invokeOnCancellation {
            biometricPrompt.cancelAuthentication()
        }
    }
}

Key Implementation Details

  1. BIP-39 Mnemonic: Use MnemonicCode from cardano-client-lib for generation and validation
  2. CIP-1852 Derivation: Account class handles this internally:
    • Path: m/1852'/1815'/0'/0/0 for first external address
    • Path: m/1852'/1815'/0'/2/0 for staking key
  3. Storage layers:
    • Mnemonic → AES-GCM encrypted → EncryptedSharedPreferences
    • AES key → Android Keystore with biometric gate
  4. Per-session wallets: Each Matrix session can have its own wallet (keyed by sessionId)
  5. Keys derived on demand: Only mnemonic is stored; spending keys derived when needed

Gotchas

  • Biometric fallback: Some devices only have PIN. Use AUTH_DEVICE_CREDENTIAL as fallback.
  • Keystore invalidation: If user changes biometrics, keys may be invalidated. Handle KeyPermanentlyInvalidatedException.
  • Memory zeroization: Clear mnemonic bytes after use (Arrays.fill(bytes, 0.toByte())).
  • Thread safety: Keystore operations are blocking; always use Dispatchers.IO.
  • cardano-client-lib entropy: Generate entropy yourself with SecureRandom, don't rely on library defaults.

Task 3: Blockfrost Client

Blocks: Task 4 (Transaction Builder)
Blocked by: Task 1
Effort: 1.5 days

Acceptance criteria:

  • Fetch UTXOs for an address returns correct data
  • Fetch balance matches sum of UTXO values
  • Submit transaction returns tx hash
  • Query tx status returns confirmation count
  • Rate limiting handled gracefully (429 → exponential backoff)
  • Network errors surface as typed errors, not crashes
  • API key securely stored and injected

Files

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/BlockfrostClient.kt

package io.element.android.features.wallet.impl.cardano

import com.bloxbean.cardano.client.api.model.Utxo

interface BlockfrostClient {
    suspend fun getUtxos(address: String): Result<List<Utxo>>
    suspend fun getBalance(address: String): Result<Long>  // lovelace
    suspend fun submitTransaction(txCbor: ByteArray): Result<String>  // tx hash
    suspend fun getTransactionStatus(txHash: String): Result<TransactionStatus>
    suspend fun getProtocolParameters(): Result<ProtocolParameters>
}

data class TransactionStatus(
    val txHash: String,
    val confirmed: Boolean,
    val confirmations: Int,
    val slot: Long?,
    val blockHeight: Long?,
)

data class ProtocolParameters(
    val minFeeA: Long,        // lovelace per byte
    val minFeeB: Long,        // base fee
    val maxTxSize: Int,
    val coinsPerUtxoWord: Long,
    val poolDeposit: Long,
    val keyDeposit: Long,
)

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/BlockfrostClientImpl.kt

package io.element.android.features.wallet.impl.cardano

import com.bloxbean.cardano.client.api.model.Utxo
import com.bloxbean.cardano.client.backend.blockfrost.service.BFBackendService
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Named

@ContributesBinding(AppScope::class)
class BlockfrostClientImpl @Inject constructor(
    @Named("blockfrost_project_id") private val projectId: String,
) : BlockfrostClient {

    companion object {
        private const val MAINNET_URL = "https://cardano-mainnet.blockfrost.io/api/v0"
        private const val MAX_RETRIES = 3
        private const val INITIAL_BACKOFF_MS = 1000L
    }

    private val backendService by lazy {
        BFBackendService(MAINNET_URL, projectId)
    }

    override suspend fun getUtxos(address: String): Result<List<Utxo>> = 
        withRetry {
            withContext(Dispatchers.IO) {
                val result = backendService.utxoService.getUtxos(address, 100, 1)
                if (result.isSuccessful) {
                    Result.success(result.value)
                } else {
                    Result.failure(BlockfrostException(result.response))
                }
            }
        }

    override suspend fun getBalance(address: String): Result<Long> =
        withRetry {
            withContext(Dispatchers.IO) {
                val result = backendService.addressService.getAddressInfo(address)
                if (result.isSuccessful) {
                    val info = result.value
                    val lovelace = info.amount
                        .find { it.unit == "lovelace" }
                        ?.quantity?.toLongOrNull() ?: 0L
                    Result.success(lovelace)
                } else {
                    Result.failure(BlockfrostException(result.response))
                }
            }
        }

    override suspend fun submitTransaction(txCbor: ByteArray): Result<String> =
        withRetry {
            withContext(Dispatchers.IO) {
                val result = backendService.transactionService.submitTransaction(txCbor)
                if (result.isSuccessful) {
                    Result.success(result.value)
                } else {
                    Result.failure(BlockfrostException(result.response))
                }
            }
        }

    override suspend fun getTransactionStatus(txHash: String): Result<TransactionStatus> =
        withRetry {
            withContext(Dispatchers.IO) {
                val result = backendService.transactionService.getTransaction(txHash)
                if (result.isSuccessful) {
                    val tx = result.value
                    Result.success(TransactionStatus(
                        txHash = txHash,
                        confirmed = true,
                        confirmations = 1, // Blockfrost doesn't give confirmation count directly
                        slot = tx.slot,
                        blockHeight = tx.blockHeight,
                    ))
                } else if (result.response?.contains("404") == true) {
                    // Not yet confirmed
                    Result.success(TransactionStatus(
                        txHash = txHash,
                        confirmed = false,
                        confirmations = 0,
                        slot = null,
                        blockHeight = null,
                    ))
                } else {
                    Result.failure(BlockfrostException(result.response))
                }
            }
        }

    override suspend fun getProtocolParameters(): Result<ProtocolParameters> =
        withRetry {
            withContext(Dispatchers.IO) {
                val result = backendService.epochService.protocolParameters
                if (result.isSuccessful) {
                    val params = result.value
                    Result.success(ProtocolParameters(
                        minFeeA = params.minFeeA.toLong(),
                        minFeeB = params.minFeeB.toLong(),
                        maxTxSize = params.maxTxSize,
                        coinsPerUtxoWord = params.coinsPerUtxoSize?.toLong() ?: 4310L,
                        poolDeposit = params.poolDeposit.toLong(),
                        keyDeposit = params.keyDeposit.toLong(),
                    ))
                } else {
                    Result.failure(BlockfrostException(result.response))
                }
            }
        }

    private suspend fun <T> withRetry(block: suspend () -> Result<T>): Result<T> {
        var lastException: Throwable? = null
        var backoff = INITIAL_BACKOFF_MS
        
        repeat(MAX_RETRIES) { attempt ->
            val result = block()
            if (result.isSuccess) {
                return result
            }
            
            val exception = result.exceptionOrNull()
            lastException = exception
            
            // Check if retryable
            if (exception is BlockfrostException) {
                if (exception.isRateLimited()) {
                    delay(backoff)
                    backoff *= 2
                } else if (!exception.isRetryable()) {
                    return result
                }
            } else {
                return result
            }
        }
        
        return Result.failure(lastException ?: Exception("Max retries exceeded"))
    }
}

class BlockfrostException(val response: String?) : Exception(response) {
    fun isRateLimited(): Boolean = response?.contains("429") == true
    fun isRetryable(): Boolean = response?.let {
        it.contains("429") || it.contains("500") || it.contains("503")
    } ?: false
}

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/BlockfrostConfig.kt

package io.element.android.features.wallet.impl.cardano

import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import javax.inject.Inject

class BlockfrostConfig @Inject constructor(
    private val context: Context,
) {
    companion object {
        private const val PREFS_NAME = "blockfrost_config"
        private const val KEY_PROJECT_ID = "project_id"
    }
    
    private val masterKey by lazy {
        MasterKey.Builder(context)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
            .build()
    }
    
    private fun getPrefs() = EncryptedSharedPreferences.create(
        context,
        PREFS_NAME,
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )
    
    fun getProjectId(): String? = getPrefs().getString(KEY_PROJECT_ID, null)
    
    fun setProjectId(projectId: String) {
        getPrefs().edit().putString(KEY_PROJECT_ID, projectId).apply()
    }
}

Key Implementation Details

  1. cardano-client-lib backend: Use BFBackendService which wraps Blockfrost REST API
  2. Retry strategy: Exponential backoff for 429 (rate limit) and 5xx errors
  3. API key storage: Encrypted SharedPreferences (not EncryptedSharedPreferences if biometric not needed for API key)
  4. Thread context: All network calls on Dispatchers.IO

Gotchas

  • Rate limits: Blockfrost free tier is 10 req/sec, 500 burst. Implement backoff.
  • Mainnet vs testnet: Need to switch URL for testing. Consider environment flag.
  • UTXO pagination: Blockfrost paginates at 100. For large wallets, implement pagination.
  • API key in BuildConfig: For development; production should use remote config or encrypted storage.
  • TLS certificate pinning: Consider adding for production security.

Task 4: Transaction Builder

Blocks: Task 6 (Payment Flow UI)
Blocked by: Tasks 2, 3
Effort: 3 days

Acceptance criteria:

  • Build valid tx spending from single UTXO
  • Build valid tx spending from multiple UTXOs (coin selection)
  • Fee calculation matches on-chain acceptance
  • Change output correctly calculated
  • Transaction signed and serialized to valid CBOR
  • Insufficient funds error surfaces clearly
  • No UTXO error handled
  • Min UTXO value enforced (no dust outputs)

Files

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/TransactionBuilder.kt

package io.element.android.features.wallet.impl.cardano

interface TransactionBuilder {
    suspend fun buildPayment(
        senderAddress: String,
        recipientAddress: String,
        amountLovelace: Long,
        mnemonic: String,
    ): Result<BuiltTransaction>
    
    suspend fun estimateFee(
        senderAddress: String,
        recipientAddress: String,
        amountLovelace: Long,
    ): Result<Long>
}

data class BuiltTransaction(
    val txCbor: ByteArray,
    val txHash: String,
    val fee: Long,
    val inputsLovelace: Long,
    val outputsLovelace: Long,
    val changeLovelace: Long,
)

sealed class TransactionBuildError : Exception() {
    object InsufficientFunds : TransactionBuildError()
    object NoUtxosAvailable : TransactionBuildError()
    data class InvalidAddress(val address: String) : TransactionBuildError()
    data class AmountTooSmall(val minAmount: Long) : TransactionBuildError()
    data class BuildFailed(override val message: String) : TransactionBuildError()
}

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/TransactionBuilderImpl.kt

package io.element.android.features.wallet.impl.cardano

import com.bloxbean.cardano.client.account.Account
import com.bloxbean.cardano.client.address.AddressProvider
import com.bloxbean.cardano.client.api.model.Amount
import com.bloxbean.cardano.client.backend.blockfrost.service.BFBackendService
import com.bloxbean.cardano.client.coinselection.impl.LargestFirstUtxoSelectionStrategy
import com.bloxbean.cardano.client.common.model.Networks
import com.bloxbean.cardano.client.function.TxBuilder
import com.bloxbean.cardano.client.function.TxBuilderContext
import com.bloxbean.cardano.client.function.helper.BalanceTxBuilders
import com.bloxbean.cardano.client.function.helper.InputBuilders
import com.bloxbean.cardano.client.function.helper.SignerProviders
import com.bloxbean.cardano.client.quicktx.QuickTxBuilder
import com.bloxbean.cardano.client.quicktx.Tx
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Named

@ContributesBinding(AppScope::class)
class TransactionBuilderImpl @Inject constructor(
    @Named("blockfrost_project_id") private val projectId: String,
    private val blockfrostClient: BlockfrostClient,
) : TransactionBuilder {

    companion object {
        private const val MAINNET_URL = "https://cardano-mainnet.blockfrost.io/api/v0"
        private const val MIN_UTXO_LOVELACE = 1_000_000L  // 1 ADA minimum for outputs
    }

    private val backendService by lazy {
        BFBackendService(MAINNET_URL, projectId)
    }

    override suspend fun buildPayment(
        senderAddress: String,
        recipientAddress: String,
        amountLovelace: Long,
        mnemonic: String,
    ): Result<BuiltTransaction> = withContext(Dispatchers.IO) {
        runCatching {
            // Validate addresses
            validateAddress(senderAddress)
            validateAddress(recipientAddress)
            
            // Validate amount
            if (amountLovelace < MIN_UTXO_LOVELACE) {
                throw TransactionBuildError.AmountTooSmall(MIN_UTXO_LOVELACE)
            }
            
            // Check UTXOs exist
            val utxosResult = blockfrostClient.getUtxos(senderAddress)
            val utxos = utxosResult.getOrThrow()
            if (utxos.isEmpty()) {
                throw TransactionBuildError.NoUtxosAvailable
            }
            
            // Calculate total available
            val totalAvailable = utxos.sumOf { utxo ->
                utxo.amount.find { it.unit == "lovelace" }?.quantity?.toLongOrNull() ?: 0L
            }
            
            // Quick check for insufficient funds (rough estimate)
            if (totalAvailable < amountLovelace + 200_000) {  // rough fee estimate
                throw TransactionBuildError.InsufficientFunds
            }
            
            // Create account from mnemonic
            val account = Account(Networks.mainnet(), mnemonic)
            
            // Build transaction using QuickTx API
            val tx = Tx()
                .payToAddress(recipientAddress, Amount.lovelace(amountLovelace))
                .from(senderAddress)
            
            val quickTxBuilder = QuickTxBuilder(backendService)
            
            // Build and sign
            val result = quickTxBuilder
                .compose(tx)
                .withSigner(SignerProviders.signerFrom(account))
                .withUtxoSelectionStrategy(LargestFirstUtxoSelectionStrategy(backendService.utxoService))
                .complete()
            
            if (!result.isSuccessful) {
                // Check if it's insufficient funds
                if (result.response?.contains("insufficient", ignoreCase = true) == true ||
                    result.response?.contains("not enough", ignoreCase = true) == true) {
                    throw TransactionBuildError.InsufficientFunds
                }
                throw TransactionBuildError.BuildFailed(result.response ?: "Unknown error")
            }
            
            val signedTx = result.value
            val txBytes = signedTx.serialize()
            val txHash = signedTx.transactionId
            
            // Calculate fee from tx body
            val fee = signedTx.body.fee.toLong()
            
            // Calculate totals
            val inputsTotal = signedTx.body.inputs.sumOf { input ->
                utxos.find { it.txHash == input.transactionId && it.outputIndex == input.index }
                    ?.amount?.find { it.unit == "lovelace" }?.quantity?.toLongOrNull() ?: 0L
            }
            
            val outputsTotal = signedTx.body.outputs.sumOf { output ->
                output.value.coin.toLong()
            }
            
            val changeAmount = outputsTotal - amountLovelace
            
            BuiltTransaction(
                txCbor = txBytes,
                txHash = txHash,
                fee = fee,
                inputsLovelace = inputsTotal,
                outputsLovelace = outputsTotal,
                changeLovelace = changeAmount,
            )
        }
    }

    override suspend fun estimateFee(
        senderAddress: String,
        recipientAddress: String,
        amountLovelace: Long,
    ): Result<Long> = withContext(Dispatchers.IO) {
        runCatching {
            // Get protocol parameters
            val params = blockfrostClient.getProtocolParameters().getOrThrow()
            
            // Estimate tx size (typical simple payment is ~250-350 bytes)
            val estimatedSize = 350
            
            // fee = a * size + b
            val fee = params.minFeeA * estimatedSize + params.minFeeB
            
            fee
        }
    }

    private fun validateAddress(address: String) {
        if (!address.startsWith("addr1") && !address.startsWith("addr_test1")) {
            throw TransactionBuildError.InvalidAddress(address)
        }
        
        try {
            AddressProvider.getAddress(address)
        } catch (e: Exception) {
            throw TransactionBuildError.InvalidAddress(address)
        }
    }
}

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/CardanoWalletManager.kt

package io.element.android.features.wallet.impl.cardano

import io.element.android.features.wallet.api.WalletState
import io.element.android.features.wallet.impl.storage.CardanoKeyStorage
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject

class CardanoWalletManager @Inject constructor(
    private val keyStorage: CardanoKeyStorage,
    private val blockfrostClient: BlockfrostClient,
    private val transactionBuilder: TransactionBuilder,
) {
    private val _walletState = MutableStateFlow(WalletState(
        hasWallet = false,
        address = null,
        balanceLovelace = null,
        balanceAda = null,
        isLoading = true,
        error = null,
    ))
    val walletState: StateFlow<WalletState> = _walletState.asStateFlow()
    
    suspend fun initialize(sessionId: SessionId) {
        _walletState.value = _walletState.value.copy(isLoading = true, error = null)
        
        val hasWallet = keyStorage.hasWallet(sessionId)
        if (hasWallet) {
            val address = keyStorage.getBaseAddress(sessionId).getOrNull()
            _walletState.value = _walletState.value.copy(
                hasWallet = true,
                address = address,
                isLoading = false,
            )
            
            // Fetch balance in background
            address?.let { refreshBalance(it) }
        } else {
            _walletState.value = _walletState.value.copy(
                hasWallet = false,
                address = null,
                isLoading = false,
            )
        }
    }
    
    suspend fun refreshBalance(address: String) {
        val result = blockfrostClient.getBalance(address)
        result.onSuccess { lovelace ->
            _walletState.value = _walletState.value.copy(
                balanceLovelace = lovelace,
                balanceAda = formatAda(lovelace),
                error = null,
            )
        }.onFailure { error ->
            _walletState.value = _walletState.value.copy(
                error = error.message,
            )
        }
    }
    
    suspend fun sendPayment(
        sessionId: SessionId,
        recipientAddress: String,
        amountLovelace: Long,
    ): Result<String> {
        val mnemonic = keyStorage.getMnemonic(sessionId).getOrElse {
            return Result.failure(it)
        }
        
        val senderAddress = keyStorage.getBaseAddress(sessionId).getOrElse {
            return Result.failure(it)
        }
        
        // Build transaction
        val builtTx = transactionBuilder.buildPayment(
            senderAddress = senderAddress,
            recipientAddress = recipientAddress,
            amountLovelace = amountLovelace,
            mnemonic = mnemonic,
        ).getOrElse {
            return Result.failure(it)
        }
        
        // Submit transaction
        val txHash = blockfrostClient.submitTransaction(builtTx.txCbor).getOrElse {
            return Result.failure(it)
        }
        
        // Refresh balance
        refreshBalance(senderAddress)
        
        return Result.success(txHash)
    }
    
    private fun formatAda(lovelace: Long): String {
        val ada = lovelace / 1_000_000.0
        return "%.6f".format(ada).trimEnd('0').trimEnd('.')
    }
}

Key Implementation Details

  1. Coin selection: Use LargestFirstUtxoSelectionStrategy from cardano-client-lib
    • Selects largest UTXOs first to minimize inputs
    • Handles multi-UTXO scenarios automatically
  2. QuickTx API: High-level builder handles fee calculation, change output, serialization
  3. Signing: SignerProviders.signerFrom(account) signs with derived spending key
  4. Fee calculation: Automatic based on protocol parameters and tx size
  5. Change output: Library adds change output automatically to sender address

Gotchas

  • Min UTXO value: Outputs must be ≥1 ADA to avoid "UTxO too small" error
  • Insufficient funds edge case: When amount + fee ≈ total balance, may fail to build change output
  • UTXO exhaustion: After many small txs, may have many dust UTXOs. Consider UTXO consolidation later.
  • TTL (time-to-live): Transactions expire after ~2 hours by default. User should be warned if tx isn't submitted quickly.
  • Memory security: Zero out mnemonic array after building tx:
    val mnemonicBytes = mnemonic.toByteArray()
    try { ... } finally { mnemonicBytes.fill(0) }
    

Task 5: /pay Slash Command Parser + SuggestionsProcessor Extension

Blocks: Task 6 (Payment Flow UI)
Blocked by: Task 1
Effort: 2 days

Acceptance criteria:

  • Typing / shows "pay" as suggestion
  • /pay suggestion shows helpful description
  • Selecting /pay auto-completes to /pay
  • /pay 10 ADA @jacob parses correctly
  • /pay 10 ADA addr1q... parses correctly
  • Invalid syntax surfaces clear error
  • Pressing send with /pay ... intercepts and opens payment flow
  • User can cancel and return to composer

Files

New: features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/slash/SlashCommand.kt

package io.element.android.features.wallet.api.slash

import io.element.android.libraries.matrix.api.core.UserId

sealed interface SlashCommand {
    data class Pay(
        val amount: Double,
        val unit: PaymentUnit,
        val recipient: PaymentRecipient,
    ) : SlashCommand
}

enum class PaymentUnit(val symbol: String, val lovelaceMultiplier: Long) {
    ADA("ADA", 1_000_000L),
    LOVELACE("lovelace", 1L),
}

sealed interface PaymentRecipient {
    data class MatrixUser(val userId: UserId) : PaymentRecipient
    data class CardanoAddress(val address: String) : PaymentRecipient
}

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/slash/SlashCommandParser.kt

package io.element.android.features.wallet.impl.slash

import io.element.android.features.wallet.api.slash.PaymentRecipient
import io.element.android.features.wallet.api.slash.PaymentUnit
import io.element.android.features.wallet.api.slash.SlashCommand
import io.element.android.libraries.matrix.api.core.UserId
import javax.inject.Inject

class SlashCommandParser @Inject constructor() {

    companion object {
        // /pay <amount> <unit> <recipient>
        // e.g., /pay 10 ADA @user:matrix.org
        // e.g., /pay 5.5 ADA addr1q...
        private val PAY_REGEX = Regex(
            """^/pay\s+(\d+(?:\.\d+)?)\s+(\w+)\s+(.+)$""",
            RegexOption.IGNORE_CASE
        )
        
        // Cardano mainnet address prefix
        private val CARDANO_ADDRESS_REGEX = Regex("""^addr1[a-z0-9]+$""", RegexOption.IGNORE_CASE)
    }

    sealed class ParseResult {
        data class Success(val command: SlashCommand) : ParseResult()
        data class Error(val message: String) : ParseResult()
        object NotACommand : ParseResult()
    }

    fun parse(input: String): ParseResult {
        val trimmed = input.trim()
        
        if (!trimmed.startsWith("/")) {
            return ParseResult.NotACommand
        }
        
        if (!trimmed.startsWith("/pay", ignoreCase = true)) {
            return ParseResult.NotACommand
        }
        
        val match = PAY_REGEX.matchEntire(trimmed)
            ?: return ParseResult.Error("Invalid format. Use: /pay <amount> <unit> <@user or addr1...>")
        
        val (amountStr, unitStr, recipientStr) = match.destructured
        
        // Parse amount
        val amount = amountStr.toDoubleOrNull()
            ?: return ParseResult.Error("Invalid amount: $amountStr")
        
        if (amount <= 0) {
            return ParseResult.Error("Amount must be positive")
        }
        
        // Parse unit
        val unit = when (unitStr.uppercase()) {
            "ADA" -> PaymentUnit.ADA
            "LOVELACE" -> PaymentUnit.LOVELACE
            else -> return ParseResult.Error("Invalid unit: $unitStr. Use ADA or lovelace")
        }
        
        // Parse recipient
        val recipient = parseRecipient(recipientStr.trim())
            ?: return ParseResult.Error("Invalid recipient. Use @user:server or addr1...")
        
        return ParseResult.Success(SlashCommand.Pay(
            amount = amount,
            unit = unit,
            recipient = recipient,
        ))
    }

    private fun parseRecipient(input: String): PaymentRecipient? {
        return when {
            // Matrix user ID
            input.startsWith("@") -> {
                try {
                    PaymentRecipient.MatrixUser(UserId(input))
                } catch (e: Exception) {
                    null
                }
            }
            // Cardano address
            CARDANO_ADDRESS_REGEX.matches(input) -> {
                PaymentRecipient.CardanoAddress(input)
            }
            else -> null
        }
    }

    fun isPartialPayCommand(input: String): Boolean {
        val trimmed = input.trim().lowercase()
        return trimmed.startsWith("/pay") || "/pay".startsWith(trimmed.ifEmpty { "/" })
    }
}

Modify: libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt

Add new type:

// Add to sealed interface ResolvedSuggestion:
data class Command(
    val command: String,
    val description: String,
) : ResolvedSuggestion

Full diff:

 sealed interface ResolvedSuggestion {
     data class Member(...) : ResolvedSuggestion
     data class Alias(...) : ResolvedSuggestion
+    data class Command(
+        val command: String,
+        val description: String,
+    ) : ResolvedSuggestion
 }

Modify: features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt

Replace empty command handling:

 SuggestionType.Command,
-SuggestionType.Emoji,
-is SuggestionType.Custom -> {
-    // Clear suggestions
-    emptyList()
-}
+-> {
+    val commands = listOf(
+        ResolvedSuggestion.Command("/pay", "Send ADA to someone"),
+    )
+    commands.filter { 
+        it.command.contains(suggestion.text, ignoreCase = true) 
+    }
+}
+SuggestionType.Emoji,
+is SuggestionType.Custom -> {
+    emptyList()
+}

Modify: features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt

Add slash command interception in sendMessage:

// Add import at top:
import io.element.android.features.wallet.impl.slash.SlashCommandParser

// Inject parser:
@Inject
constructor(
    // ... existing params ...
    private val slashCommandParser: SlashCommandParser,
)

// In handleEvents(), find AnalyticsEvents.Composer.SendMessage handling:
// BEFORE sending to timeline, add:

when (val parseResult = slashCommandParser.parse(message.markdown)) {
    is SlashCommandParser.ParseResult.Success -> {
        when (val command = parseResult.command) {
            is SlashCommand.Pay -> {
                // Navigate to payment flow instead of sending
                navigator.navigateToPaymentFlow(
                    roomId = room.roomId.value,
                    amount = command.amount,
                    unit = command.unit,
                    recipient = command.recipient,
                )
                return@launch
            }
        }
    }
    is SlashCommandParser.ParseResult.Error -> {
        // Show error toast/snackbar
        _state.value = _state.value.copy(
            snackbarMessage = parseResult.message
        )
        return@launch
    }
    SlashCommandParser.ParseResult.NotACommand -> {
        // Continue normal send flow
    }
}

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/slash/RecipientAddressResolver.kt

package io.element.android.features.wallet.impl.slash

import io.element.android.features.wallet.api.slash.PaymentRecipient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import javax.inject.Inject

/**
 * Resolves a Matrix user to a Cardano address.
 * 
 * Phase 1: Returns null for Matrix users (address must be entered manually)
 * Phase 2: Will query user's account data for published Cardano address
 */
class RecipientAddressResolver @Inject constructor() {

    sealed class ResolveResult {
        data class Resolved(val address: String) : ResolveResult()
        object NeedsManualEntry : ResolveResult()
        data class Error(val message: String) : ResolveResult()
    }

    suspend fun resolve(
        recipient: PaymentRecipient,
        room: MatrixRoom,
    ): ResolveResult {
        return when (recipient) {
            is PaymentRecipient.CardanoAddress -> {
                // Validate address format
                if (isValidCardanoAddress(recipient.address)) {
                    ResolveResult.Resolved(recipient.address)
                } else {
                    ResolveResult.Error("Invalid Cardano address format")
                }
            }
            is PaymentRecipient.MatrixUser -> {
                // Phase 1: We can't resolve Matrix user to Cardano address
                // User will need to enter address manually in payment UI
                ResolveResult.NeedsManualEntry
            }
        }
    }

    private fun isValidCardanoAddress(address: String): Boolean {
        // Basic validation: mainnet addresses start with addr1
        // More thorough validation happens in TransactionBuilder
        return address.startsWith("addr1") && address.length >= 50
    }
}

Key Implementation Details

  1. Syntax: /pay <amount> <unit> <recipient>

    • Amount: decimal number (e.g., 10, 5.5)
    • Unit: ADA or lovelace (case-insensitive)
    • Recipient: @user:server or addr1...
  2. Suggestions: When user types /, show "pay" as autocomplete option

  3. Interception point: In MessageComposerPresenter.sendMessage(), check if message is a slash command BEFORE sending to timeline

  4. Matrix user resolution: Phase 1 doesn't resolve @user to address — payment UI will prompt for address entry

Gotchas

  • Partial commands: /pa shouldn't error — user is still typing
  • Case sensitivity: Commands should be case-insensitive (/PAY = /pay)
  • Whitespace: Handle multiple spaces between tokens
  • Address validation: Basic format check in parser; full validation in TransactionBuilder
  • Room context: Payment requires room context to resolve @mentions to actual user IDs
  • Edit prevention: Don't allow editing messages that were slash commands (they didn't actually send)

Task 6: Payment Flow UI

Blocks: Task 7 (Payment Card)
Blocked by: Tasks 4, 5
Effort: 3 days

Acceptance criteria:

  • Confirmation screen shows recipient, amount, fee estimate
  • Manual address entry field when recipient is @user
  • "Confirm" triggers biometric authentication
  • Success state shows tx hash
  • Error states display appropriate messages
  • "Cancel" returns to composer
  • Loading state during tx submission
  • All states match Element X design patterns

Files

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentFlowState.kt

package io.element.android.features.wallet.impl.payment

import io.element.android.features.wallet.api.slash.PaymentRecipient
import io.element.android.features.wallet.api.slash.PaymentUnit

data class PaymentFlowState(
    val step: PaymentStep,
    val amount: Double,
    val unit: PaymentUnit,
    val originalRecipient: PaymentRecipient,
    val resolvedAddress: String?,
    val addressInput: String,
    val estimatedFee: Long?,
    val estimatedFeeAda: String?,
    val totalAmountAda: String?,
    val senderAddress: String?,
    val senderBalance: Long?,
    val senderBalanceAda: String?,
    val txHash: String?,
    val error: PaymentError?,
    val eventActions: PaymentFlowEvents,
)

sealed interface PaymentStep {
    object Loading : PaymentStep
    object EnterAddress : PaymentStep  // When @user can't be resolved
    object Confirm : PaymentStep
    object Authenticating : PaymentStep
    object Submitting : PaymentStep
    object Success : PaymentStep
    object Error : PaymentStep
}

sealed interface PaymentError {
    object InsufficientFunds : PaymentError
    object InvalidAddress : PaymentError
    object NetworkError : PaymentError
    object AuthenticationFailed : PaymentError
    object AuthenticationCancelled : PaymentError
    data class TransactionFailed(val message: String) : PaymentError
}

interface PaymentFlowEvents {
    fun onAddressChanged(address: String)
    fun onConfirmAddress()
    fun onConfirmPayment()
    fun onAuthenticationResult(success: Boolean, error: String?)
    fun onCancel()
    fun onDismissError()
    fun onDone()
}

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentFlowPresenter.kt

package io.element.android.features.wallet.impl.payment

import androidx.compose.runtime.*
import com.bloxbean.cardano.client.common.model.Networks
import io.element.android.features.wallet.api.slash.PaymentRecipient
import io.element.android.features.wallet.api.slash.PaymentUnit
import io.element.android.features.wallet.impl.biometric.BiometricAuthenticator
import io.element.android.features.wallet.impl.cardano.BlockfrostClient
import io.element.android.features.wallet.impl.cardano.CardanoWalletManager
import io.element.android.features.wallet.impl.cardano.TransactionBuildError
import io.element.android.features.wallet.impl.cardano.TransactionBuilder
import io.element.android.features.wallet.impl.storage.CardanoKeyStorage
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.launch
import javax.inject.Inject

class PaymentFlowPresenter @Inject constructor(
    private val matrixClient: MatrixClient,
    private val walletManager: CardanoWalletManager,
    private val keyStorage: CardanoKeyStorage,
    private val transactionBuilder: TransactionBuilder,
    private val blockfrostClient: BlockfrostClient,
) : Presenter<PaymentFlowState> {

    private var amount: Double = 0.0
    private var unit: PaymentUnit = PaymentUnit.ADA
    private var originalRecipient: PaymentRecipient? = null
    private var roomId: String? = null

    fun initialize(
        roomId: String,
        amount: Double,
        unit: PaymentUnit,
        recipient: PaymentRecipient,
    ) {
        this.roomId = roomId
        this.amount = amount
        this.unit = unit
        this.originalRecipient = recipient
    }

    @Composable
    override fun present(): PaymentFlowState {
        val scope = rememberCoroutineScope()
        val sessionId = matrixClient.sessionId
        
        var step by remember { mutableStateOf<PaymentStep>(PaymentStep.Loading) }
        var resolvedAddress by remember { mutableStateOf<String?>(null) }
        var addressInput by remember { mutableStateOf("") }
        var estimatedFee by remember { mutableStateOf<Long?>(null) }
        var senderAddress by remember { mutableStateOf<String?>(null) }
        var senderBalance by remember { mutableStateOf<Long?>(null) }
        var txHash by remember { mutableStateOf<String?>(null) }
        var error by remember { mutableStateOf<PaymentError?>(null) }

        // Initialize
        LaunchedEffect(Unit) {
            // Get sender address
            val address = keyStorage.getBaseAddress(sessionId).getOrNull()
            senderAddress = address
            
            // Get balance
            address?.let {
                blockfrostClient.getBalance(it).onSuccess { balance ->
                    senderBalance = balance
                }
            }
            
            // Check recipient type
            when (val recipient = originalRecipient) {
                is PaymentRecipient.CardanoAddress -> {
                    resolvedAddress = recipient.address
                    addressInput = recipient.address
                    step = PaymentStep.Confirm
                }
                is PaymentRecipient.MatrixUser -> {
                    // Can't resolve in Phase 1 — need manual entry
                    step = PaymentStep.EnterAddress
                }
                null -> {
                    step = PaymentStep.EnterAddress
                }
            }
            
            // Estimate fee
            val recipientAddr = resolvedAddress ?: "addr1qxck..." // dummy for estimation
            transactionBuilder.estimateFee(
                senderAddress = address ?: return@LaunchedEffect,
                recipientAddress = recipientAddr,
                amountLovelace = (amount * unit.lovelaceMultiplier).toLong(),
            ).onSuccess { fee ->
                estimatedFee = fee
            }
        }

        val amountLovelace = (amount * unit.lovelaceMultiplier).toLong()
        val amountAda = amountLovelace / 1_000_000.0
        val feeAda = estimatedFee?.let { it / 1_000_000.0 }
        val totalAda = feeAda?.let { amountAda + it }

        val events = remember {
            object : PaymentFlowEvents {
                override fun onAddressChanged(address: String) {
                    addressInput = address
                }
                
                override fun onConfirmAddress() {
                    if (isValidCardanoAddress(addressInput)) {
                        resolvedAddress = addressInput
                        step = PaymentStep.Confirm
                    } else {
                        error = PaymentError.InvalidAddress
                    }
                }
                
                override fun onConfirmPayment() {
                    step = PaymentStep.Authenticating
                }
                
                override fun onAuthenticationResult(success: Boolean, errorMsg: String?) {
                    if (success) {
                        step = PaymentStep.Submitting
                        scope.launch {
                            submitTransaction(
                                sessionId = sessionId,
                                recipientAddress = resolvedAddress!!,
                                amountLovelace = amountLovelace,
                                onSuccess = { hash ->
                                    txHash = hash
                                    step = PaymentStep.Success
                                },
                                onError = { err ->
                                    error = err
                                    step = PaymentStep.Error
                                }
                            )
                        }
                    } else {
                        error = if (errorMsg?.contains("cancel", ignoreCase = true) == true) {
                            PaymentError.AuthenticationCancelled
                        } else {
                            PaymentError.AuthenticationFailed
                        }
                        step = PaymentStep.Error
                    }
                }
                
                override fun onCancel() {
                    // Navigator handles back
                }
                
                override fun onDismissError() {
                    error = null
                    step = PaymentStep.Confirm
                }
                
                override fun onDone() {
                    // Navigator handles finish
                }
            }
        }

        return PaymentFlowState(
            step = step,
            amount = amount,
            unit = unit,
            originalRecipient = originalRecipient ?: PaymentRecipient.CardanoAddress(""),
            resolvedAddress = resolvedAddress,
            addressInput = addressInput,
            estimatedFee = estimatedFee,
            estimatedFeeAda = feeAda?.let { "%.6f".format(it) },
            totalAmountAda = totalAda?.let { "%.6f".format(it) },
            senderAddress = senderAddress,
            senderBalance = senderBalance,
            senderBalanceAda = senderBalance?.let { "%.6f".format(it / 1_000_000.0) },
            txHash = txHash,
            error = error,
            eventActions = events,
        )
    }

    private suspend fun submitTransaction(
        sessionId: io.element.android.libraries.matrix.api.core.SessionId,
        recipientAddress: String,
        amountLovelace: Long,
        onSuccess: (String) -> Unit,
        onError: (PaymentError) -> Unit,
    ) {
        val result = walletManager.sendPayment(
            sessionId = sessionId,
            recipientAddress = recipientAddress,
            amountLovelace = amountLovelace,
        )
        
        result.onSuccess { hash ->
            // Send Matrix event
            sendPaymentEvent(
                txHash = hash,
                recipientAddress = recipientAddress,
                amountLovelace = amountLovelace,
            )
            onSuccess(hash)
        }.onFailure { throwable ->
            val paymentError = when (throwable) {
                is TransactionBuildError.InsufficientFunds -> PaymentError.InsufficientFunds
                is TransactionBuildError.InvalidAddress -> PaymentError.InvalidAddress
                is TransactionBuildError.NoUtxosAvailable -> PaymentError.InsufficientFunds
                else -> PaymentError.TransactionFailed(throwable.message ?: "Unknown error")
            }
            onError(paymentError)
        }
    }

    private suspend fun sendPaymentEvent(
        txHash: String,
        recipientAddress: String,
        amountLovelace: Long,
    ) {
        // Send m.payment.cardano event to room
        val room = matrixClient.getRoom(io.element.android.libraries.matrix.api.core.RoomId(roomId!!))
        room?.let {
            // This will be implemented in Task 7
            // it.sendPaymentEvent(txHash, recipientAddress, amountLovelace)
        }
    }

    private fun isValidCardanoAddress(address: String): Boolean {
        return address.startsWith("addr1") && address.length >= 50
    }
}

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentFlowScreen.kt

package io.element.android.features.wallet.impl.payment

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.theme.components.*

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaymentFlowScreen(
    state: PaymentFlowState,
    onNavigateBack: () -> Unit,
    onAuthenticateRequest: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Send Payment") },
                navigationIcon = {
                    BackButton(onClick = {
                        state.eventActions.onCancel()
                        onNavigateBack()
                    })
                }
            )
        },
        modifier = modifier,
    ) { padding ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(padding)
        ) {
            when (state.step) {
                PaymentStep.Loading -> {
                    CircularProgressIndicator(
                        modifier = Modifier.align(Alignment.Center)
                    )
                }
                
                PaymentStep.EnterAddress -> {
                    EnterAddressContent(
                        state = state,
                        modifier = Modifier.fillMaxSize(),
                    )
                }
                
                PaymentStep.Confirm -> {
                    ConfirmContent(
                        state = state,
                        onConfirm = {
                            state.eventActions.onConfirmPayment()
                            onAuthenticateRequest()
                        },
                        modifier = Modifier.fillMaxSize(),
                    )
                }
                
                PaymentStep.Authenticating -> {
                    AuthenticatingContent(
                        modifier = Modifier.fillMaxSize(),
                    )
                }
                
                PaymentStep.Submitting -> {
                    SubmittingContent(
                        modifier = Modifier.fillMaxSize(),
                    )
                }
                
                PaymentStep.Success -> {
                    SuccessContent(
                        txHash = state.txHash ?: "",
                        onDone = {
                            state.eventActions.onDone()
                            onNavigateBack()
                        },
                        modifier = Modifier.fillMaxSize(),
                    )
                }
                
                PaymentStep.Error -> {
                    ErrorContent(
                        error = state.error,
                        onRetry = { state.eventActions.onDismissError() },
                        onCancel = {
                            state.eventActions.onCancel()
                            onNavigateBack()
                        },
                        modifier = Modifier.fillMaxSize(),
                    )
                }
            }
        }
    }
}

@Composable
private fun EnterAddressContent(
    state: PaymentFlowState,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier.padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        Text(
            text = "Enter recipient's Cardano address",
            style = MaterialTheme.typography.titleMedium,
        )
        
        when (val recipient = state.originalRecipient) {
            is io.element.android.features.wallet.api.slash.PaymentRecipient.MatrixUser -> {
                Text(
                    text = "Sending to ${recipient.userId.value}",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
                Text(
                    text = "Enter their Cardano address below:",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
            }
            else -> {}
        }
        
        OutlinedTextField(
            value = state.addressInput,
            onValueChange = { state.eventActions.onAddressChanged(it) },
            label = { Text("Cardano Address") },
            placeholder = { Text("addr1q...") },
            modifier = Modifier.fillMaxWidth(),
            singleLine = true,
            isError = state.error == PaymentError.InvalidAddress,
            supportingText = if (state.error == PaymentError.InvalidAddress) {
                { Text("Invalid Cardano address") }
            } else null,
        )
        
        Spacer(modifier = Modifier.weight(1f))
        
        Button(
            onClick = { state.eventActions.onConfirmAddress() },
            modifier = Modifier.fillMaxWidth(),
            enabled = state.addressInput.isNotBlank(),
        ) {
            Text("Continue")
        }
    }
}

@Composable
private fun ConfirmContent(
    state: PaymentFlowState,
    onConfirm: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier.padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        // Amount card
        Card(
            modifier = Modifier.fillMaxWidth(),
        ) {
            Column(
                modifier = Modifier.padding(16.dp),
                horizontalAlignment = Alignment.CenterHorizontally,
            ) {
                Text(
                    text = "Amount",
                    style = MaterialTheme.typography.labelMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
                Text(
                    text = "${state.amount} ${state.unit.symbol}",
                    style = MaterialTheme.typography.headlineLarge,
                )
            }
        }
        
        // Details
        Card(
            modifier = Modifier.fillMaxWidth(),
        ) {
            Column(
                modifier = Modifier.padding(16.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp),
            ) {
                DetailRow("To", state.resolvedAddress?.take(20) + "..." ?: "Unknown")
                DetailRow("Network Fee", state.estimatedFeeAda?.let { "~$it ADA" } ?: "Calculating...")
                Divider()
                DetailRow(
                    "Total",
                    state.totalAmountAda?.let { "$it ADA" } ?: "Calculating...",
                    bold = true
                )
            }
        }
        
        // Balance warning
        state.senderBalance?.let { balance ->
            val amountLovelace = (state.amount * state.unit.lovelaceMultiplier).toLong()
            val feeEstimate = state.estimatedFee ?: 200_000L
            if (balance < amountLovelace + feeEstimate) {
                Card(
                    colors = CardDefaults.cardColors(
                        containerColor = MaterialTheme.colorScheme.errorContainer
                    ),
                    modifier = Modifier.fillMaxWidth(),
                ) {
                    Text(
                        text = "⚠️ Insufficient balance (${state.senderBalanceAda} ADA available)",
                        modifier = Modifier.padding(16.dp),
                        color = MaterialTheme.colorScheme.onErrorContainer,
                    )
                }
            }
        }
        
        Spacer(modifier = Modifier.weight(1f))
        
        Button(
            onClick = onConfirm,
            modifier = Modifier.fillMaxWidth(),
        ) {
            Icon(Icons.Default.Send, contentDescription = null)
            Spacer(modifier = Modifier.width(8.dp))
            Text("Confirm & Authenticate")
        }
    }
}

@Composable
private fun DetailRow(
    label: String,
    value: String,
    bold: Boolean = false,
    modifier: Modifier = Modifier,
) {
    Row(
        modifier = modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween,
    ) {
        Text(
            text = label,
            style = if (bold) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium,
        )
        Text(
            text = value,
            style = if (bold) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium,
        )
    }
}

@Composable
private fun AuthenticatingContent(modifier: Modifier = Modifier) {
    Box(modifier = modifier, contentAlignment = Alignment.Center) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            CircularProgressIndicator()
            Spacer(modifier = Modifier.height(16.dp))
            Text("Authenticate to continue...")
        }
    }
}

@Composable
private fun SubmittingContent(modifier: Modifier = Modifier) {
    Box(modifier = modifier, contentAlignment = Alignment.Center) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            CircularProgressIndicator()
            Spacer(modifier = Modifier.height(16.dp))
            Text("Submitting transaction...")
        }
    }
}

@Composable
private fun SuccessContent(
    txHash: String,
    onDone: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier.padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
    ) {
        Icon(
            Icons.Default.Check,
            contentDescription = null,
            tint = MaterialTheme.colorScheme.primary,
            modifier = Modifier.size(64.dp),
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Text(
            text = "Payment Sent!",
            style = MaterialTheme.typography.headlineMedium,
        )
        
        Spacer(modifier = Modifier.height(8.dp))
        
        Text(
            text = "Transaction: ${txHash.take(16)}...",
            style = MaterialTheme.typography.bodySmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
        
        Spacer(modifier = Modifier.height(24.dp))
        
        Button(onClick = onDone) {
            Text("Done")
        }
    }
}

@Composable
private fun ErrorContent(
    error: PaymentError?,
    onRetry: () -> Unit,
    onCancel: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier.padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
    ) {
        Icon(
            Icons.Default.Close,
            contentDescription = null,
            tint = MaterialTheme.colorScheme.error,
            modifier = Modifier.size(64.dp),
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Text(
            text = "Payment Failed",
            style = MaterialTheme.typography.headlineMedium,
        )
        
        Spacer(modifier = Modifier.height(8.dp))
        
        Text(
            text = when (error) {
                PaymentError.InsufficientFunds -> "Not enough ADA in your wallet"
                PaymentError.InvalidAddress -> "Invalid recipient address"
                PaymentError.NetworkError -> "Network error. Please try again."
                PaymentError.AuthenticationFailed -> "Authentication failed"
                PaymentError.AuthenticationCancelled -> "Authentication cancelled"
                is PaymentError.TransactionFailed -> error.message
                null -> "Unknown error"
            },
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
        
        Spacer(modifier = Modifier.height(24.dp))
        
        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
            OutlinedButton(onClick = onCancel) {
                Text("Cancel")
            }
            Button(onClick = onRetry) {
                Text("Try Again")
            }
        }
    }
}

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentFlowNode.kt

package io.element.android.features.wallet.impl.payment

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.fragment.app.FragmentActivity
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.wallet.api.slash.PaymentRecipient
import io.element.android.features.wallet.api.slash.PaymentUnit
import io.element.android.features.wallet.impl.biometric.BiometricAuthenticator
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.launch

@ContributesNode(SessionScope::class)
class PaymentFlowNode @AssistedInject constructor(
    @Assisted buildContext: BuildContext,
    @Assisted plugins: List<Plugin>,
    private val presenter: PaymentFlowPresenter,
    private val biometricAuthenticator: BiometricAuthenticator,
) : Node(buildContext, plugins = plugins) {

    data class Inputs(
        val roomId: String,
        val amount: Double,
        val unit: PaymentUnit,
        val recipient: PaymentRecipient,
    ) : NodeInputs

    private val inputs: Inputs = inputs()

    init {
        presenter.initialize(
            roomId = inputs.roomId,
            amount = inputs.amount,
            unit = inputs.unit,
            recipient = inputs.recipient,
        )
    }

    @Composable
    override fun View(modifier: Modifier) {
        val state = presenter.present()
        
        PaymentFlowScreen(
            state = state,
            onNavigateBack = { navigateUp() },
            onAuthenticateRequest = {
                lifecycleScope.launch {
                    val activity = requireActivity() as FragmentActivity
                    val result = biometricAuthenticator.authenticate(
                        activity = activity,
                        title = "Confirm Payment",
                        subtitle = "Authenticate to send ${state.amount} ${state.unit.symbol}",
                    )
                    
                    when (result) {
                        BiometricAuthenticator.AuthResult.Success -> {
                            state.eventActions.onAuthenticationResult(true, null)
                        }
                        is BiometricAuthenticator.AuthResult.Error -> {
                            state.eventActions.onAuthenticationResult(false, result.message)
                        }
                        BiometricAuthenticator.AuthResult.Cancelled -> {
                            state.eventActions.onAuthenticationResult(false, "cancelled")
                        }
                    }
                }
            },
            modifier = modifier,
        )
    }

    @AssistedFactory
    interface Factory {
        fun create(
            buildContext: BuildContext,
            plugins: List<Plugin>,
        ): PaymentFlowNode
    }
}

Key Implementation Details

  1. State machine: Loading → EnterAddress (if needed) → Confirm → Authenticating → Submitting → Success/Error
  2. Biometric trigger: Called from Node when entering Authenticating step
  3. Presenter pattern: Follows Element X conventions with @Composable present() returning state
  4. Node pattern: Uses Appyx for navigation, @ContributesNode for DI

Gotchas

  • Activity context for biometrics: Need FragmentActivity from the node, not Application context
  • Lifecycle scope: Biometric callback must complete even if composition changes
  • Back handling: Cancel must clean up state and navigate back
  • Fee estimation timing: Estimate immediately on load; update if address changes
  • Keyboard handling: Address input should show appropriate keyboard, dismiss on confirm

Task 7: Payment Card Timeline Item

Blocks: Nothing
Blocked by: Tasks 1, 6, 8
Effort: 2.5 days

Acceptance criteria:

  • m.payment.cardano event sends to room
  • Payment card renders for sender
  • Payment card renders for recipient
  • Card shows amount, status, explorer link
  • Tapping explorer link opens CardanoScan
  • PENDING status updates to CONFIRMED (polling)
  • Unknown/old clients show fallback text

Files

New: features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/timeline/PaymentEventContent.kt

package io.element.android.features.wallet.api.timeline

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
 * Content for m.payment.cardano events.
 * 
 * Example JSON:
 * {
 *   "msgtype": "m.payment.cardano",
 *   "body": "Sent 10 ADA",
 *   "chain": "cardano",
 *   "network": "mainnet",
 *   "tx_hash": "abc123...",
 *   "sender_address": "addr1q...",
 *   "recipient_address": "addr1q...",
 *   "amount_lovelace": "10000000",
 *   "status": "pending"
 * }
 */
@Serializable
data class PaymentEventContent(
    @SerialName("msgtype")
    val msgtype: String = "m.payment.cardano",
    
    @SerialName("body")
    val body: String,  // Fallback text for clients that don't support this
    
    @SerialName("chain")
    val chain: String = "cardano",
    
    @SerialName("network")
    val network: String = "mainnet",
    
    @SerialName("tx_hash")
    val txHash: String?,
    
    @SerialName("sender_address")
    val senderAddress: String,
    
    @SerialName("recipient_address")
    val recipientAddress: String,
    
    @SerialName("amount_lovelace")
    val amountLovelace: String,  // String to avoid precision issues
    
    @SerialName("status")
    val status: String,  // "pending", "confirmed", "failed"
)

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemPaymentContent.kt

package io.element.android.features.wallet.impl.timeline

import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent

data class TimelineItemPaymentContent(
    val txHash: String?,
    val senderAddress: String,
    val recipientAddress: String,
    val amountLovelace: Long,
    val amountAda: String,
    val status: PaymentStatus,
    val network: String,
    val isMine: Boolean,  // Did current user send this?
) : TimelineItemEventContent {
    override val type: String = "m.payment.cardano"
}

enum class PaymentStatus {
    PENDING,
    CONFIRMED,
    FAILED,
}

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemPaymentView.kt

package io.element.android.features.wallet.impl.timeline

import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowOutward
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp

@Composable
fun TimelineItemPaymentView(
    content: TimelineItemPaymentContent,
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current
    
    Card(
        modifier = modifier.widthIn(max = 280.dp),
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.secondaryContainer
        ),
    ) {
        Column(
            modifier = Modifier.padding(12.dp),
        ) {
            // Header row
            Row(
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.spacedBy(8.dp),
            ) {
                // Cardano logo placeholder
                Text(
                    text = "₳",
                    style = MaterialTheme.typography.titleLarge,
                    color = MaterialTheme.colorScheme.primary,
                )
                
                Text(
                    text = if (content.isMine) "Payment Sent" else "Payment Received",
                    style = MaterialTheme.typography.labelLarge,
                )
                
                Spacer(modifier = Modifier.weight(1f))
                
                // Status icon
                when (content.status) {
                    PaymentStatus.PENDING -> Icon(
                        Icons.Default.Schedule,
                        contentDescription = "Pending",
                        tint = MaterialTheme.colorScheme.onSurfaceVariant,
                        modifier = Modifier.size(16.dp),
                    )
                    PaymentStatus.CONFIRMED -> Icon(
                        Icons.Default.CheckCircle,
                        contentDescription = "Confirmed",
                        tint = MaterialTheme.colorScheme.primary,
                        modifier = Modifier.size(16.dp),
                    )
                    PaymentStatus.FAILED -> Icon(
                        Icons.Default.Error,
                        contentDescription = "Failed",
                        tint = MaterialTheme.colorScheme.error,
                        modifier = Modifier.size(16.dp),
                    )
                }
            }
            
            Spacer(modifier = Modifier.height(8.dp))
            
            // Amount
            Text(
                text = "${content.amountAda} ADA",
                style = MaterialTheme.typography.headlineSmall,
                fontWeight = FontWeight.Bold,
            )
            
            Spacer(modifier = Modifier.height(4.dp))
            
            // Address (truncated)
            Text(
                text = if (content.isMine) {
                    "To: ${content.recipientAddress.take(12)}...${content.recipientAddress.takeLast(8)}"
                } else {
                    "From: ${content.senderAddress.take(12)}...${content.senderAddress.takeLast(8)}"
                },
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
            
            // Explorer link
            content.txHash?.let { hash ->
                Spacer(modifier = Modifier.height(8.dp))
                
                Row(
                    modifier = Modifier
                        .clickable {
                            val url = "https://cardanoscan.io/transaction/$hash"
                            val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
                            context.startActivity(intent)
                        }
                        .padding(vertical = 4.dp),
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    Text(
                        text = "View on CardanoScan",
                        style = MaterialTheme.typography.labelMedium,
                        color = MaterialTheme.colorScheme.primary,
                    )
                    Icon(
                        Icons.Default.ArrowOutward,
                        contentDescription = null,
                        tint = MaterialTheme.colorScheme.primary,
                        modifier = Modifier.size(14.dp),
                    )
                }
            }
        }
    }
}

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemPaymentFactory.kt

package io.element.android.features.wallet.impl.timeline

import io.element.android.features.wallet.api.timeline.PaymentEventContent
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Inject

class TimelineItemPaymentFactory @Inject constructor(
    private val json: Json,
) {
    fun create(
        rawContent: JsonObject,
        currentUserId: UserId,
        senderUserId: UserId,
    ): TimelineItemPaymentContent? {
        return try {
            val msgtype = rawContent["msgtype"]?.jsonPrimitive?.content
            if (msgtype != "m.payment.cardano") return null
            
            val content = json.decodeFromJsonElement(
                PaymentEventContent.serializer(),
                rawContent
            )
            
            val amountLovelace = content.amountLovelace.toLongOrNull() ?: return null
            val amountAda = "%.6f".format(amountLovelace / 1_000_000.0)
                .trimEnd('0')
                .trimEnd('.')
            
            val status = when (content.status.lowercase()) {
                "pending" -> PaymentStatus.PENDING
                "confirmed" -> PaymentStatus.CONFIRMED
                "failed" -> PaymentStatus.FAILED
                else -> PaymentStatus.PENDING
            }
            
            TimelineItemPaymentContent(
                txHash = content.txHash,
                senderAddress = content.senderAddress,
                recipientAddress = content.recipientAddress,
                amountLovelace = amountLovelace,
                amountAda = amountAda,
                status = status,
                network = content.network,
                isMine = senderUserId == currentUserId,
            )
        } catch (e: Exception) {
            null
        }
    }
}

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/PaymentStatusPoller.kt

package io.element.android.features.wallet.impl.timeline

import io.element.android.features.wallet.impl.cardano.BlockfrostClient
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject

/**
 * Polls Blockfrost to update payment status from PENDING to CONFIRMED.
 * Updates the Matrix event when status changes.
 */
class PaymentStatusPoller @Inject constructor(
    private val blockfrostClient: BlockfrostClient,
) {
    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
    
    private val _pendingPayments = MutableStateFlow<Set<String>>(emptySet())
    val pendingPayments: StateFlow<Set<String>> = _pendingPayments
    
    fun startPolling(
        txHash: String,
        eventId: String,
        room: MatrixRoom,
    ) {
        _pendingPayments.value = _pendingPayments.value + txHash
        
        scope.launch {
            var confirmed = false
            var attempts = 0
            val maxAttempts = 60  // Poll for ~10 minutes
            
            while (!confirmed && attempts < maxAttempts) {
                delay(10_000)  // 10 seconds between polls
                attempts++
                
                val result = blockfrostClient.getTransactionStatus(txHash)
                result.onSuccess { status ->
                    if (status.confirmed) {
                        confirmed = true
                        _pendingPayments.value = _pendingPayments.value - txHash
                        
                        // Update Matrix event with confirmed status
                        // Note: This requires the ability to edit events or send a relation
                        // For MVP, we might just update local state
                    }
                }
            }
            
            if (!confirmed) {
                _pendingPayments.value = _pendingPayments.value - txHash
            }
        }
    }
    
    fun stopAll() {
        scope.coroutineContext.cancelChildren()
        _pendingPayments.value = emptySet()
    }
}

Modify: features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt

Add payment event handling:

// Add import:
import io.element.android.features.wallet.impl.timeline.TimelineItemPaymentFactory
import io.element.android.features.wallet.impl.timeline.TimelineItemPaymentContent

// Inject factory:
@Inject constructor(
    // ... existing ...
    private val paymentFactory: TimelineItemPaymentFactory,
)

// In create() method, add case for raw JSON content handling:
// When content is UnknownContent, try to parse as payment:
is UnknownContent -> {
    // Try payment first
    val rawJson = /* get raw JSON from event */
    paymentFactory.create(rawJson, currentUserId, senderUserId)
        ?: TimelineItemUnknownContent
}

Modify: features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt

Add payment case:

// Add import:
import io.element.android.features.wallet.impl.timeline.TimelineItemPaymentContent
import io.element.android.features.wallet.impl.timeline.TimelineItemPaymentView

// In the when block:
is TimelineItemPaymentContent -> TimelineItemPaymentView(
    content = content,
    modifier = modifier,
)

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/room/PaymentEventSender.kt

package io.element.android.features.wallet.impl.room

import io.element.android.features.wallet.api.timeline.PaymentEventContent
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject

class PaymentEventSender @Inject constructor(
    private val json: Json,
) {
    suspend fun sendPaymentEvent(
        room: MatrixRoom,
        txHash: String,
        senderAddress: String,
        recipientAddress: String,
        amountLovelace: Long,
    ): Result<String> {
        val content = PaymentEventContent(
            body = "Sent ${amountLovelace / 1_000_000.0} ADA",
            txHash = txHash,
            senderAddress = senderAddress,
            recipientAddress = recipientAddress,
            amountLovelace = amountLovelace.toString(),
            status = "pending",
        )
        
        // Send as raw JSON event
        // This uses the SDK's ability to send arbitrary message content
        return room.sendMessage(
            body = content.body,
            htmlBody = null,
            mentions = emptyList(),
        ).map { eventId ->
            // TODO: Actually send as m.room.message with custom msgtype
            // This requires SDK extension (Task 8)
            eventId.value
        }
    }
}

Key Implementation Details

  1. Event schema: Custom msgtype of m.payment.cardano with standard m.room.message event type
  2. Fallback text: body field contains human-readable text for clients without payment support
  3. Status tracking: Initially "pending", updated to "confirmed" via polling
  4. Explorer URL: CardanoScan for mainnet: https://cardanoscan.io/transaction/{txHash}
  5. Factory registration: Payment factory must be called before falling back to UnknownContent

Gotchas

  • Event editing: Updating status requires event editing or relation events. For MVP, may just track locally.
  • Raw JSON access: Need access to raw event JSON to parse custom content. SDK may not expose this cleanly.
  • Cross-client rendering: Other Matrix clients will see fallback body text only.
  • Network switch: network field should match (mainnet vs testnet). Don't mix.
  • Amount precision: Store as string to avoid floating point issues with lovelace.

Task 8: SDK Extension — Register m.payment.cardano Event Type

Blocks: Task 7 (fully functional)
Blocked by: Task 1
Effort: 2 days

Acceptance criteria:

  • SDK recognizes m.payment.cardano msgtype
  • Raw JSON content accessible from Kotlin
  • Can send custom msgtype via SDK
  • Event parsing doesn't crash on unknown content
  • Existing message types unaffected

Files

Analysis: Where Event Types Are Registered

In matrix-rust-sdk (the SDK Element X uses), event parsing happens in Rust. The Kotlin bindings expose:

libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/
├── EventTimelineItemMapper.kt
├── TimelineEventContentMapper.kt
└── ... 

The TimelineEventContentMapper converts Rust SDK types to Kotlin types.

For unknown message types, the SDK returns OtherState or falls back gracefully.

Approach: Client-Side Custom Parsing

Since modifying the Rust SDK is complex and we want Phase 1 to work without forking the SDK:

  1. Intercept at the Kotlin layer: After SDK returns timeline items, check for messages with our custom msgtype
  2. Access raw content: The SDK does provide raw JSON for unknown content
  3. Custom parsing: Parse m.payment.cardano content ourselves

New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/sdk/PaymentEventParser.kt

package io.element.android.features.wallet.impl.sdk

import io.element.android.features.wallet.api.timeline.PaymentEventContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Inject

class PaymentEventParser @Inject constructor(
    private val json: Json,
) {
    /**
     * Checks if a message content is a payment event and parses it.
     */
    fun tryParse(content: MessageContent): PaymentEventContent? {
        // Check if it's an "other" message type (custom msgtype)
        val messageType = content.type
        if (messageType !is OtherMessageType) return null
        
        // Check if msgtype matches
        if (messageType.msgtype != "m.payment.cardano") return null
        
        // Try to parse the raw content
        return try {
            // The SDK should expose raw content for OtherMessageType
            // This might be in messageType.body or a raw JSON field
            // Exact API depends on SDK version
            
            // Attempt to parse from available data
            val rawJson = messageType.rawContent ?: return null
            json.decodeFromJsonElement(PaymentEventContent.serializer(), rawJson)
        } catch (e: Exception) {
            null
        }
    }
}

Modify: SDK Binding (if raw content not exposed)

If the SDK doesn't expose raw JSON for custom message types, we need a minimal extension.

Option A: Fork and patch matrix-rust-sdk-bindings

In crates/matrix-sdk-ffi/src/timeline/content.rs, add:

pub struct OtherMessageType {
    pub msgtype: String,
    pub body: String,
    pub raw_content: Option<String>,  // ADD THIS
}

Then regenerate Kotlin bindings.

Option B: Use m.room.message with formatted_body hack

Store payment JSON in formatted_body:

{
  "msgtype": "m.text",
  "body": "Sent 10 ADA",
  "format": "io.element.payment.cardano",
  "formatted_body": "{\"tx_hash\":\"...\", ...}"
}

This is hacky but works without SDK changes.

Option C: Send as State Event

Use a custom state event type instead of room message:

  • Event type: com.sulkta.payment
  • State key: transaction ID

Pro: Full JSON control
Con: State events aren't rendered in timeline by default

Use Option B (formatted_body hack) for MVP:

// PaymentEventSender.kt - Updated
suspend fun sendPaymentEvent(
    room: MatrixRoom,
    txHash: String,
    senderAddress: String,
    recipientAddress: String,
    amountLovelace: Long,
): Result<String> {
    val paymentJson = json.encodeToString(PaymentEventContent(
        body = "Sent ${formatAda(amountLovelace)} ADA",
        txHash = txHash,
        senderAddress = senderAddress,
        recipientAddress = recipientAddress,
        amountLovelace = amountLovelace.toString(),
        status = "pending",
    ))
    
    // Send as text message with custom format marker
    return room.sendMessage(
        body = "Sent ${formatAda(amountLovelace)} ADA",
        htmlBody = paymentJson,  // Abuse formatted_body for JSON
        format = "io.element.payment.cardano",  // Custom format marker
        mentions = emptyList(),
    )
}

// TimelineItemPaymentFactory.kt - Updated parsing
fun create(content: MessageContent, ...): TimelineItemPaymentContent? {
    // Check for our custom format
    if (content.formattedBody != null && 
        content.format == "io.element.payment.cardano") {
        return try {
            val paymentContent = json.decodeFromString<PaymentEventContent>(
                content.formattedBody!!
            )
            // ... convert to TimelineItemPaymentContent
        } catch (e: Exception) {
            null
        }
    }
    return null
}

Key Implementation Details

  1. MVP approach: Use formatted_body to store JSON, with custom format marker
  2. Detection: Check format field for our marker before parsing
  3. Fallback: body contains human-readable text for other clients
  4. Future: Proper SDK extension in Phase 2

Gotchas

  • formatted_body parsing: Other clients will see raw JSON if they render formatted_body. Consider encoding.
  • SDK changes: If Element updates SDK, our parsing might break. Pin SDK version.
  • Event editing: To update payment status, we need event editing support.
  • Redaction: Users can delete payment events. Handle gracefully.
  • E2EE: Payment events in encrypted rooms still work — content encrypted same as regular messages.

Summary: Build Order

Week 1:
├── Task 1: Module Scaffolding (1 day) ─────────┐
├── Task 8: SDK Extension (2 days) ─────────────┤
└── Task 2: Key Storage (3 days) ───────────────┤
                                                │
Week 2:                                         │
├── Task 3: Blockfrost Client (1.5 days) ───────┤
├── Task 4: Transaction Builder (3 days) ←──────┘
└── Task 5: Slash Command (2 days)

Week 3:
├── Task 6: Payment Flow UI (3 days)
└── Task 7: Payment Card (2.5 days)

Buffer: 2 days for integration testing and bug fixes

Total Estimate: 18 working days (~3.5 weeks)


Post-MVP: Phase 2 Teasers

  1. SSSS Integration: Store encrypted wallet seed in Matrix account data
  2. Cross-device sync: Restore wallet on new device after verification
  3. Recipient address lookup: Query recipient's published Cardano address from their Matrix profile
  4. Balance widget: Show wallet balance in room header
  5. Transaction history: Query and display past payments
  6. Multi-asset support: Handle native tokens

Document prepared for Element X ADA project. Ready for implementation.