diff --git a/PHASE1-PLAN.md b/PHASE1-PLAN.md new file mode 100644 index 0000000000..e184a16ee1 --- /dev/null +++ b/PHASE1-PLAN.md @@ -0,0 +1,3127 @@ +# 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` +```kotlin +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` +```kotlin +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` +```kotlin +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` +```kotlin +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` +```kotlin +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` +```kotlin +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: +```kotlin +include(":features:wallet:api") +include(":features:wallet:impl") +include(":features:wallet:test") +``` + +#### Modify: `app/build.gradle.kts` +Add dependency: +```kotlin +implementation(projects.features.wallet.impl) +``` + +#### New: `gradle.properties` addition +```properties +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` +```kotlin +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 + suspend fun importWallet(sessionId: SessionId, mnemonic: String): Result + suspend fun getMnemonic(sessionId: SessionId): Result + suspend fun getSpendingKey(sessionId: SessionId, addressIndex: Int = 0): Result + suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int = 0): Result + suspend fun getStakeAddress(sessionId: SessionId): Result + suspend fun deleteWallet(sessionId: SessionId): Result +} + +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` +```kotlin +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 = + 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 = + 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 = + withContext(Dispatchers.IO) { + runCatching { + retrieveMnemonic(sessionId) + } + } + + override suspend fun getSpendingKey(sessionId: SessionId, addressIndex: Int): Result = + 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 = + withContext(Dispatchers.IO) { + runCatching { + val mnemonic = retrieveMnemonic(sessionId) + val account = Account(Networks.mainnet(), mnemonic, addressIndex) + account.baseAddress() + } + } + + override suspend fun getStakeAddress(sessionId: SessionId): Result = + withContext(Dispatchers.IO) { + runCatching { + val mnemonic = retrieveMnemonic(sessionId) + val account = Account(Networks.mainnet(), mnemonic) + account.stakeAddress() + } + } + + override suspend fun deleteWallet(sessionId: SessionId): Result = + 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` +```kotlin +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, + 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` +```kotlin +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` +```kotlin +package io.element.android.features.wallet.impl.cardano + +import com.bloxbean.cardano.client.api.model.Utxo + +interface BlockfrostClient { + suspend fun getUtxos(address: String): Result> + suspend fun getBalance(address: String): Result // lovelace + suspend fun submitTransaction(txCbor: ByteArray): Result // tx hash + suspend fun getTransactionStatus(txHash: String): Result + suspend fun getProtocolParameters(): Result +} + +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` +```kotlin +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> = + 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 = + 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 = + 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 = + 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 = + 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 withRetry(block: suspend () -> Result): Result { + 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` +```kotlin +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` +```kotlin +package io.element.android.features.wallet.impl.cardano + +interface TransactionBuilder { + suspend fun buildPayment( + senderAddress: String, + recipientAddress: String, + amountLovelace: Long, + mnemonic: String, + ): Result + + suspend fun estimateFee( + senderAddress: String, + recipientAddress: String, + amountLovelace: Long, + ): Result +} + +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` +```kotlin +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 = 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 = 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` +```kotlin +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.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 { + 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: + ```kotlin + 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` +```kotlin +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` +```kotlin +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 + // 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 <@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: +```kotlin +// Add to sealed interface ResolvedSuggestion: +data class Command( + val command: String, + val description: String, +) : ResolvedSuggestion +``` + +Full diff: +```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: +```diff + 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`: +```kotlin +// 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` +```kotlin +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: 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` +```kotlin +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` +```kotlin +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 { + + 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.Loading) } + var resolvedAddress by remember { mutableStateOf(null) } + var addressInput by remember { mutableStateOf("") } + var estimatedFee by remember { mutableStateOf(null) } + var senderAddress by remember { mutableStateOf(null) } + var senderBalance by remember { mutableStateOf(null) } + var txHash by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(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` +```kotlin +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` +```kotlin +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, + 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, + ): 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` +```kotlin +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` +```kotlin +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` +```kotlin +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` +```kotlin +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` +```kotlin +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>(emptySet()) + val pendingPayments: StateFlow> = _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: +```kotlin +// 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: +```kotlin +// 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` +```kotlin +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 { + 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` +```kotlin +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: +```rust +pub struct OtherMessageType { + pub msgtype: String, + pub body: String, + pub raw_content: Option, // ADD THIS +} +``` + +Then regenerate Kotlin bindings. + +**Option B: Use m.room.message with formatted_body hack** + +Store payment JSON in `formatted_body`: +```json +{ + "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 + +#### Recommended Approach for Phase 1 + +Use **Option B** (formatted_body hack) for MVP: + +```kotlin +// PaymentEventSender.kt - Updated +suspend fun sendPaymentEvent( + room: MatrixRoom, + txHash: String, + senderAddress: String, + recipientAddress: String, + amountLovelace: Long, +): Result { + 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( + 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.*