108 KiB
Element X ADA Wallet — Phase 1 Implementation Plan
Date: 2026-03-27
Author: Kayos
Target: Local-only MVP — /pay 10 ADA @jacob end-to-end
Repo: Sulkta-Coop/element-x-ada
Overview
Phase 1 delivers a functional Cardano lite wallet embedded in Element X Android:
- User types
/pay 10 ADA @jacobin a DM - Confirmation screen opens with amount + recipient
- Biometric authentication triggers
- Transaction is signed and submitted via Blockfrost
- Payment card renders in timeline for both parties
- Recipient taps to view tx on CardanoScan
Constraints:
- Local-only (no SSSS sync — Phase 2)
- Keys stored in Android Keystore
- Blockfrost for chain queries
cardano-client-lib(pure Java, no JNI)- Custom Matrix event:
m.payment.cardano
Dependency Graph
┌─────────────────────────────────────────────────────────────────────┐
│ BUILD ORDER │
└─────────────────────────────────────────────────────────────────────┘
Task 1: Module Scaffolding
│
├──────────────────┬──────────────────┐
▼ ▼ ▼
Task 2: Key Storage Task 3: Blockfrost Task 8: SDK Extension
│ │ │
└────────┬─────────┘ │
▼ │
Task 4: Transaction Builder │
│ │
├─────────────────────────────────┘
▼
Task 5: Slash Command Parser
│
▼
Task 6: Payment Flow UI
│
▼
Task 7: Payment Card Timeline
Task 1: features/wallet/ Module Scaffolding
Blocks: Tasks 2, 3, 4, 5, 6, 7
Blocked by: Nothing
Effort: 1 day
Acceptance criteria:
- Module compiles with
./gradlew :features:wallet:impl:assemble - DI module loads without errors
- WalletEntryPoint accessible from app module
- Unit test infrastructure works (
./gradlew :features:wallet:impl:test)
Files
New: features/wallet/api/build.gradle.kts
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.wallet.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
}
New: features/wallet/impl/build.gradle.kts
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.features.wallet.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.features.wallet.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.impl)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.cryptography.api)
implementation(projects.libraries.cryptography.impl)
implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
// Cardano
implementation("com.bloxbean.cardano:cardano-client-lib:0.7.1")
implementation("com.bloxbean.cardano:cardano-client-backend-blockfrost:0.7.1")
implementation("com.bloxbean.cardano:cardano-client-crypto:0.7.1")
// Biometric
implementation(libs.androidx.biometric)
// JSON
implementation(libs.serialization.json)
// Coroutines
implementation(libs.coroutines.core)
testImplementation(projects.features.wallet.test)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.coroutines.test)
}
New: features/wallet/test/build.gradle.kts
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.wallet.test"
}
dependencies {
api(projects.features.wallet.api)
implementation(libs.coroutines.core)
}
New: features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletEntryPoint.kt
package io.element.android.features.wallet.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
interface WalletEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun params(params: Params): NodeBuilder
fun build(): Node
}
data class Params(
val roomId: String,
val recipientUserId: String?,
val recipientAddress: String?,
val amount: String?,
)
}
New: features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletState.kt
package io.element.android.features.wallet.api
data class WalletState(
val hasWallet: Boolean,
val address: String?,
val balanceLovelace: Long?,
val balanceAda: String?,
val isLoading: Boolean,
val error: String?,
)
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/di/WalletModule.kt
package io.element.android.features.wallet.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import dagger.Provides
import io.element.android.features.wallet.api.WalletEntryPoint
import io.element.android.features.wallet.impl.DefaultWalletEntryPoint
import io.element.android.features.wallet.impl.cardano.BlockfrostClient
import io.element.android.features.wallet.impl.cardano.BlockfrostClientImpl
import io.element.android.features.wallet.impl.storage.CardanoKeyStorage
import io.element.android.features.wallet.impl.storage.CardanoKeyStorageImpl
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import javax.inject.Named
@Module
@ContributesTo(AppScope::class)
interface WalletModule {
@Binds
fun bindWalletEntryPoint(impl: DefaultWalletEntryPoint): WalletEntryPoint
@Binds
fun bindCardanoKeyStorage(impl: CardanoKeyStorageImpl): CardanoKeyStorage
@Binds
fun bindBlockfrostClient(impl: BlockfrostClientImpl): BlockfrostClient
companion object {
@Provides
@Named("blockfrost_project_id")
fun provideBlockfrostProjectId(): String {
// TODO: Move to BuildConfig or encrypted storage
return BuildConfig.BLOCKFROST_PROJECT_ID
}
}
}
Modify: settings.gradle.kts (root)
Add to features section:
include(":features:wallet:api")
include(":features:wallet:impl")
include(":features:wallet:test")
Modify: app/build.gradle.kts
Add dependency:
implementation(projects.features.wallet.impl)
New: gradle.properties addition
BLOCKFROST_PROJECT_ID=mainnetXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Key Implementation Details
- Module structure follows Element X pattern:
api/impl/testseparation - Anvil for DI: Use
@ContributesBindingand@ContributesToannotations - AppScope: Wallet services are app-scoped (singleton per app lifecycle)
- SessionScope consideration: If wallet-per-account is needed later, migrate to SessionScope
Gotchas
- ProGuard rules:
cardano-client-libuses reflection for CBOR. Add toproguard-rules.pro:-keep class com.bloxbean.cardano.** { *; } -keepclassmembers class * { @com.fasterxml.jackson.annotation.* *; } - Multidex: The Cardano library is large. Ensure multidex is enabled (it should be already).
- minSdk: cardano-client-lib requires API 21+. Element X is API 23+, so we're fine.
Task 2: Key Generation + Storage (CardanoKeyStorage)
Blocks: Task 4 (Transaction Builder)
Blocked by: Task 1
Effort: 3 days
Acceptance criteria:
- New wallet generates valid 24-word BIP-39 mnemonic
- Mnemonic encrypts with Android Keystore key
- Mnemonic decrypts correctly after biometric auth
- Derived addresses match cardano-client-lib reference
- Seed phrase backup screen shows words with FLAG_SECURE
- Wallet deletion clears all key material
- Unit tests pass for key derivation paths
Files
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/storage/CardanoKeyStorage.kt
package io.element.android.features.wallet.impl.storage
import io.element.android.libraries.matrix.api.core.SessionId
interface CardanoKeyStorage {
suspend fun hasWallet(sessionId: SessionId): Boolean
suspend fun createWallet(sessionId: SessionId): Result<WalletCreationResult>
suspend fun importWallet(sessionId: SessionId, mnemonic: String): Result<Unit>
suspend fun getMnemonic(sessionId: SessionId): Result<String>
suspend fun getSpendingKey(sessionId: SessionId, addressIndex: Int = 0): Result<ByteArray>
suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int = 0): Result<String>
suspend fun getStakeAddress(sessionId: SessionId): Result<String>
suspend fun deleteWallet(sessionId: SessionId): Result<Unit>
}
data class WalletCreationResult(
val mnemonic: String,
val baseAddress: String,
val stakeAddress: String,
)
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/storage/CardanoKeyStorageImpl.kt
package io.element.android.features.wallet.impl.storage
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.bloxbean.cardano.client.account.Account
import com.bloxbean.cardano.client.common.model.Networks
import com.bloxbean.cardano.client.crypto.bip39.MnemonicCode
import com.bloxbean.cardano.client.crypto.bip39.Words
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.cryptography.api.SecretKeyRepository
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class CardanoKeyStorageImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val secretKeyRepository: SecretKeyRepository,
) : CardanoKeyStorage {
companion object {
private const val PREFS_NAME = "cardano_wallet_prefs"
private const val KEY_ENCRYPTED_MNEMONIC = "encrypted_mnemonic_"
private const val KEY_IV = "iv_"
private const val KEYSTORE_ALIAS_PREFIX = "cardano_wallet_"
private const val GCM_TAG_LENGTH = 128
private const val GCM_IV_LENGTH = 12
}
private val masterKey by lazy {
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(
30, // validity duration in seconds
MasterKey.AUTH_BIOMETRIC_STRONG or MasterKey.AUTH_DEVICE_CREDENTIAL
)
.build()
}
private fun getPrefs() = EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
override suspend fun hasWallet(sessionId: SessionId): Boolean = withContext(Dispatchers.IO) {
val prefs = getPrefs()
prefs.contains(KEY_ENCRYPTED_MNEMONIC + sessionId.value)
}
override suspend fun createWallet(sessionId: SessionId): Result<WalletCreationResult> =
withContext(Dispatchers.IO) {
runCatching {
// Generate 24-word mnemonic (256 bits entropy)
val mnemonicCode = MnemonicCode()
val entropy = ByteArray(32).also { SecureRandom().nextBytes(it) }
val mnemonic = mnemonicCode.toMnemonic(entropy).joinToString(" ")
// Store encrypted
storeMnemonic(sessionId, mnemonic)
// Derive addresses
val account = Account(Networks.mainnet(), mnemonic)
WalletCreationResult(
mnemonic = mnemonic,
baseAddress = account.baseAddress(),
stakeAddress = account.stakeAddress(),
)
}
}
override suspend fun importWallet(sessionId: SessionId, mnemonic: String): Result<Unit> =
withContext(Dispatchers.IO) {
runCatching {
// Validate mnemonic
val words = mnemonic.trim().split("\\s+".toRegex())
require(words.size in listOf(12, 15, 18, 21, 24)) {
"Invalid mnemonic length: ${words.size} words"
}
// Verify it's valid BIP-39
val mnemonicCode = MnemonicCode()
mnemonicCode.check(words)
// Verify it derives valid Cardano addresses
Account(Networks.mainnet(), mnemonic)
// Store encrypted
storeMnemonic(sessionId, mnemonic)
}
}
override suspend fun getMnemonic(sessionId: SessionId): Result<String> =
withContext(Dispatchers.IO) {
runCatching {
retrieveMnemonic(sessionId)
}
}
override suspend fun getSpendingKey(sessionId: SessionId, addressIndex: Int): Result<ByteArray> =
withContext(Dispatchers.IO) {
runCatching {
val mnemonic = retrieveMnemonic(sessionId)
val account = Account(Networks.mainnet(), mnemonic, addressIndex)
account.privateKeyBytes()
}
}
override suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int): Result<String> =
withContext(Dispatchers.IO) {
runCatching {
val mnemonic = retrieveMnemonic(sessionId)
val account = Account(Networks.mainnet(), mnemonic, addressIndex)
account.baseAddress()
}
}
override suspend fun getStakeAddress(sessionId: SessionId): Result<String> =
withContext(Dispatchers.IO) {
runCatching {
val mnemonic = retrieveMnemonic(sessionId)
val account = Account(Networks.mainnet(), mnemonic)
account.stakeAddress()
}
}
override suspend fun deleteWallet(sessionId: SessionId): Result<Unit> =
withContext(Dispatchers.IO) {
runCatching {
val prefs = getPrefs()
prefs.edit()
.remove(KEY_ENCRYPTED_MNEMONIC + sessionId.value)
.remove(KEY_IV + sessionId.value)
.apply()
// Delete keystore key
secretKeyRepository.deleteKey(KEYSTORE_ALIAS_PREFIX + sessionId.value)
}
}
private fun storeMnemonic(sessionId: SessionId, mnemonic: String) {
val keyAlias = KEYSTORE_ALIAS_PREFIX + sessionId.value
val secretKey = secretKeyRepository.getOrCreateKey(
alias = keyAlias,
requiresUserAuthentication = true
)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val iv = cipher.iv
val encrypted = cipher.doFinal(mnemonic.toByteArray(Charsets.UTF_8))
val prefs = getPrefs()
prefs.edit()
.putString(KEY_ENCRYPTED_MNEMONIC + sessionId.value,
android.util.Base64.encodeToString(encrypted, android.util.Base64.NO_WRAP))
.putString(KEY_IV + sessionId.value,
android.util.Base64.encodeToString(iv, android.util.Base64.NO_WRAP))
.apply()
}
private fun retrieveMnemonic(sessionId: SessionId): String {
val prefs = getPrefs()
val encryptedB64 = prefs.getString(KEY_ENCRYPTED_MNEMONIC + sessionId.value, null)
?: throw IllegalStateException("No wallet found for session")
val ivB64 = prefs.getString(KEY_IV + sessionId.value, null)
?: throw IllegalStateException("No IV found for session")
val encrypted = android.util.Base64.decode(encryptedB64, android.util.Base64.NO_WRAP)
val iv = android.util.Base64.decode(ivB64, android.util.Base64.NO_WRAP)
val keyAlias = KEYSTORE_ALIAS_PREFIX + sessionId.value
val secretKey = secretKeyRepository.getOrCreateKey(
alias = keyAlias,
requiresUserAuthentication = true
)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
return String(cipher.doFinal(encrypted), Charsets.UTF_8)
}
}
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/SeedPhraseBackupScreen.kt
package io.element.android.features.wallet.impl.setup
import android.view.WindowManager
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun SeedPhraseBackupScreen(
words: List<String>,
onConfirm: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
// FLAG_SECURE to prevent screenshots
val view = LocalView.current
DisposableEffect(Unit) {
val window = (view.context as? android.app.Activity)?.window
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
onDispose {
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Your Recovery Phrase",
style = MaterialTheme.typography.headlineMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Write down these 24 words in order. Never share them with anyone.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(24.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(3),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f),
) {
itemsIndexed(words) { index, word ->
SeedWordItem(index = index + 1, word = word)
}
}
Spacer(modifier = Modifier.height(24.dp))
Button(
text = "I've written it down",
onClick = onConfirm,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onBack) {
Text("Go back")
}
}
}
@Composable
private fun SeedWordItem(
index: Int,
word: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "$index.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = word,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/biometric/BiometricAuthenticator.kt
package io.element.android.features.wallet.impl.biometric
import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import kotlin.coroutines.resume
class BiometricAuthenticator @Inject constructor() {
sealed class AuthResult {
object Success : AuthResult()
data class Error(val code: Int, val message: String) : AuthResult()
object Cancelled : AuthResult()
}
fun canAuthenticate(context: Context): Boolean {
val biometricManager = BiometricManager.from(context)
return biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
) == BiometricManager.BIOMETRIC_SUCCESS
}
suspend fun authenticate(
activity: FragmentActivity,
title: String = "Authenticate",
subtitle: String = "Confirm your identity to continue",
negativeButtonText: String = "Cancel",
): AuthResult = suspendCancellableCoroutine { continuation ->
val executor = ContextCompat.getMainExecutor(activity)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
if (continuation.isActive) {
continuation.resume(AuthResult.Success)
}
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (continuation.isActive) {
if (errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
errorCode == BiometricPrompt.ERROR_CANCELED) {
continuation.resume(AuthResult.Cancelled)
} else {
continuation.resume(AuthResult.Error(errorCode, errString.toString()))
}
}
}
override fun onAuthenticationFailed() {
// Don't resume yet - user can retry
}
}
val biometricPrompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
.build()
biometricPrompt.authenticate(promptInfo)
continuation.invokeOnCancellation {
biometricPrompt.cancelAuthentication()
}
}
}
Key Implementation Details
- BIP-39 Mnemonic: Use
MnemonicCodefrom cardano-client-lib for generation and validation - CIP-1852 Derivation:
Accountclass handles this internally:- Path:
m/1852'/1815'/0'/0/0for first external address - Path:
m/1852'/1815'/0'/2/0for staking key
- Path:
- Storage layers:
- Mnemonic → AES-GCM encrypted → EncryptedSharedPreferences
- AES key → Android Keystore with biometric gate
- Per-session wallets: Each Matrix session can have its own wallet (keyed by
sessionId) - Keys derived on demand: Only mnemonic is stored; spending keys derived when needed
Gotchas
- Biometric fallback: Some devices only have PIN. Use
AUTH_DEVICE_CREDENTIALas fallback. - Keystore invalidation: If user changes biometrics, keys may be invalidated. Handle
KeyPermanentlyInvalidatedException. - Memory zeroization: Clear mnemonic bytes after use (
Arrays.fill(bytes, 0.toByte())). - Thread safety: Keystore operations are blocking; always use
Dispatchers.IO. - cardano-client-lib entropy: Generate entropy yourself with
SecureRandom, don't rely on library defaults.
Task 3: Blockfrost Client
Blocks: Task 4 (Transaction Builder)
Blocked by: Task 1
Effort: 1.5 days
Acceptance criteria:
- Fetch UTXOs for an address returns correct data
- Fetch balance matches sum of UTXO values
- Submit transaction returns tx hash
- Query tx status returns confirmation count
- Rate limiting handled gracefully (429 → exponential backoff)
- Network errors surface as typed errors, not crashes
- API key securely stored and injected
Files
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/BlockfrostClient.kt
package io.element.android.features.wallet.impl.cardano
import com.bloxbean.cardano.client.api.model.Utxo
interface BlockfrostClient {
suspend fun getUtxos(address: String): Result<List<Utxo>>
suspend fun getBalance(address: String): Result<Long> // lovelace
suspend fun submitTransaction(txCbor: ByteArray): Result<String> // tx hash
suspend fun getTransactionStatus(txHash: String): Result<TransactionStatus>
suspend fun getProtocolParameters(): Result<ProtocolParameters>
}
data class TransactionStatus(
val txHash: String,
val confirmed: Boolean,
val confirmations: Int,
val slot: Long?,
val blockHeight: Long?,
)
data class ProtocolParameters(
val minFeeA: Long, // lovelace per byte
val minFeeB: Long, // base fee
val maxTxSize: Int,
val coinsPerUtxoWord: Long,
val poolDeposit: Long,
val keyDeposit: Long,
)
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/BlockfrostClientImpl.kt
package io.element.android.features.wallet.impl.cardano
import com.bloxbean.cardano.client.api.model.Utxo
import com.bloxbean.cardano.client.backend.blockfrost.service.BFBackendService
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Named
@ContributesBinding(AppScope::class)
class BlockfrostClientImpl @Inject constructor(
@Named("blockfrost_project_id") private val projectId: String,
) : BlockfrostClient {
companion object {
private const val MAINNET_URL = "https://cardano-mainnet.blockfrost.io/api/v0"
private const val MAX_RETRIES = 3
private const val INITIAL_BACKOFF_MS = 1000L
}
private val backendService by lazy {
BFBackendService(MAINNET_URL, projectId)
}
override suspend fun getUtxos(address: String): Result<List<Utxo>> =
withRetry {
withContext(Dispatchers.IO) {
val result = backendService.utxoService.getUtxos(address, 100, 1)
if (result.isSuccessful) {
Result.success(result.value)
} else {
Result.failure(BlockfrostException(result.response))
}
}
}
override suspend fun getBalance(address: String): Result<Long> =
withRetry {
withContext(Dispatchers.IO) {
val result = backendService.addressService.getAddressInfo(address)
if (result.isSuccessful) {
val info = result.value
val lovelace = info.amount
.find { it.unit == "lovelace" }
?.quantity?.toLongOrNull() ?: 0L
Result.success(lovelace)
} else {
Result.failure(BlockfrostException(result.response))
}
}
}
override suspend fun submitTransaction(txCbor: ByteArray): Result<String> =
withRetry {
withContext(Dispatchers.IO) {
val result = backendService.transactionService.submitTransaction(txCbor)
if (result.isSuccessful) {
Result.success(result.value)
} else {
Result.failure(BlockfrostException(result.response))
}
}
}
override suspend fun getTransactionStatus(txHash: String): Result<TransactionStatus> =
withRetry {
withContext(Dispatchers.IO) {
val result = backendService.transactionService.getTransaction(txHash)
if (result.isSuccessful) {
val tx = result.value
Result.success(TransactionStatus(
txHash = txHash,
confirmed = true,
confirmations = 1, // Blockfrost doesn't give confirmation count directly
slot = tx.slot,
blockHeight = tx.blockHeight,
))
} else if (result.response?.contains("404") == true) {
// Not yet confirmed
Result.success(TransactionStatus(
txHash = txHash,
confirmed = false,
confirmations = 0,
slot = null,
blockHeight = null,
))
} else {
Result.failure(BlockfrostException(result.response))
}
}
}
override suspend fun getProtocolParameters(): Result<ProtocolParameters> =
withRetry {
withContext(Dispatchers.IO) {
val result = backendService.epochService.protocolParameters
if (result.isSuccessful) {
val params = result.value
Result.success(ProtocolParameters(
minFeeA = params.minFeeA.toLong(),
minFeeB = params.minFeeB.toLong(),
maxTxSize = params.maxTxSize,
coinsPerUtxoWord = params.coinsPerUtxoSize?.toLong() ?: 4310L,
poolDeposit = params.poolDeposit.toLong(),
keyDeposit = params.keyDeposit.toLong(),
))
} else {
Result.failure(BlockfrostException(result.response))
}
}
}
private suspend fun <T> withRetry(block: suspend () -> Result<T>): Result<T> {
var lastException: Throwable? = null
var backoff = INITIAL_BACKOFF_MS
repeat(MAX_RETRIES) { attempt ->
val result = block()
if (result.isSuccess) {
return result
}
val exception = result.exceptionOrNull()
lastException = exception
// Check if retryable
if (exception is BlockfrostException) {
if (exception.isRateLimited()) {
delay(backoff)
backoff *= 2
} else if (!exception.isRetryable()) {
return result
}
} else {
return result
}
}
return Result.failure(lastException ?: Exception("Max retries exceeded"))
}
}
class BlockfrostException(val response: String?) : Exception(response) {
fun isRateLimited(): Boolean = response?.contains("429") == true
fun isRetryable(): Boolean = response?.let {
it.contains("429") || it.contains("500") || it.contains("503")
} ?: false
}
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/BlockfrostConfig.kt
package io.element.android.features.wallet.impl.cardano
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import javax.inject.Inject
class BlockfrostConfig @Inject constructor(
private val context: Context,
) {
companion object {
private const val PREFS_NAME = "blockfrost_config"
private const val KEY_PROJECT_ID = "project_id"
}
private val masterKey by lazy {
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
}
private fun getPrefs() = EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun getProjectId(): String? = getPrefs().getString(KEY_PROJECT_ID, null)
fun setProjectId(projectId: String) {
getPrefs().edit().putString(KEY_PROJECT_ID, projectId).apply()
}
}
Key Implementation Details
- cardano-client-lib backend: Use
BFBackendServicewhich wraps Blockfrost REST API - Retry strategy: Exponential backoff for 429 (rate limit) and 5xx errors
- API key storage: Encrypted SharedPreferences (not EncryptedSharedPreferences if biometric not needed for API key)
- Thread context: All network calls on
Dispatchers.IO
Gotchas
- Rate limits: Blockfrost free tier is 10 req/sec, 500 burst. Implement backoff.
- Mainnet vs testnet: Need to switch URL for testing. Consider environment flag.
- UTXO pagination: Blockfrost paginates at 100. For large wallets, implement pagination.
- API key in BuildConfig: For development; production should use remote config or encrypted storage.
- TLS certificate pinning: Consider adding for production security.
Task 4: Transaction Builder
Blocks: Task 6 (Payment Flow UI)
Blocked by: Tasks 2, 3
Effort: 3 days
Acceptance criteria:
- Build valid tx spending from single UTXO
- Build valid tx spending from multiple UTXOs (coin selection)
- Fee calculation matches on-chain acceptance
- Change output correctly calculated
- Transaction signed and serialized to valid CBOR
- Insufficient funds error surfaces clearly
- No UTXO error handled
- Min UTXO value enforced (no dust outputs)
Files
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/TransactionBuilder.kt
package io.element.android.features.wallet.impl.cardano
interface TransactionBuilder {
suspend fun buildPayment(
senderAddress: String,
recipientAddress: String,
amountLovelace: Long,
mnemonic: String,
): Result<BuiltTransaction>
suspend fun estimateFee(
senderAddress: String,
recipientAddress: String,
amountLovelace: Long,
): Result<Long>
}
data class BuiltTransaction(
val txCbor: ByteArray,
val txHash: String,
val fee: Long,
val inputsLovelace: Long,
val outputsLovelace: Long,
val changeLovelace: Long,
)
sealed class TransactionBuildError : Exception() {
object InsufficientFunds : TransactionBuildError()
object NoUtxosAvailable : TransactionBuildError()
data class InvalidAddress(val address: String) : TransactionBuildError()
data class AmountTooSmall(val minAmount: Long) : TransactionBuildError()
data class BuildFailed(override val message: String) : TransactionBuildError()
}
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/TransactionBuilderImpl.kt
package io.element.android.features.wallet.impl.cardano
import com.bloxbean.cardano.client.account.Account
import com.bloxbean.cardano.client.address.AddressProvider
import com.bloxbean.cardano.client.api.model.Amount
import com.bloxbean.cardano.client.backend.blockfrost.service.BFBackendService
import com.bloxbean.cardano.client.coinselection.impl.LargestFirstUtxoSelectionStrategy
import com.bloxbean.cardano.client.common.model.Networks
import com.bloxbean.cardano.client.function.TxBuilder
import com.bloxbean.cardano.client.function.TxBuilderContext
import com.bloxbean.cardano.client.function.helper.BalanceTxBuilders
import com.bloxbean.cardano.client.function.helper.InputBuilders
import com.bloxbean.cardano.client.function.helper.SignerProviders
import com.bloxbean.cardano.client.quicktx.QuickTxBuilder
import com.bloxbean.cardano.client.quicktx.Tx
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Named
@ContributesBinding(AppScope::class)
class TransactionBuilderImpl @Inject constructor(
@Named("blockfrost_project_id") private val projectId: String,
private val blockfrostClient: BlockfrostClient,
) : TransactionBuilder {
companion object {
private const val MAINNET_URL = "https://cardano-mainnet.blockfrost.io/api/v0"
private const val MIN_UTXO_LOVELACE = 1_000_000L // 1 ADA minimum for outputs
}
private val backendService by lazy {
BFBackendService(MAINNET_URL, projectId)
}
override suspend fun buildPayment(
senderAddress: String,
recipientAddress: String,
amountLovelace: Long,
mnemonic: String,
): Result<BuiltTransaction> = withContext(Dispatchers.IO) {
runCatching {
// Validate addresses
validateAddress(senderAddress)
validateAddress(recipientAddress)
// Validate amount
if (amountLovelace < MIN_UTXO_LOVELACE) {
throw TransactionBuildError.AmountTooSmall(MIN_UTXO_LOVELACE)
}
// Check UTXOs exist
val utxosResult = blockfrostClient.getUtxos(senderAddress)
val utxos = utxosResult.getOrThrow()
if (utxos.isEmpty()) {
throw TransactionBuildError.NoUtxosAvailable
}
// Calculate total available
val totalAvailable = utxos.sumOf { utxo ->
utxo.amount.find { it.unit == "lovelace" }?.quantity?.toLongOrNull() ?: 0L
}
// Quick check for insufficient funds (rough estimate)
if (totalAvailable < amountLovelace + 200_000) { // rough fee estimate
throw TransactionBuildError.InsufficientFunds
}
// Create account from mnemonic
val account = Account(Networks.mainnet(), mnemonic)
// Build transaction using QuickTx API
val tx = Tx()
.payToAddress(recipientAddress, Amount.lovelace(amountLovelace))
.from(senderAddress)
val quickTxBuilder = QuickTxBuilder(backendService)
// Build and sign
val result = quickTxBuilder
.compose(tx)
.withSigner(SignerProviders.signerFrom(account))
.withUtxoSelectionStrategy(LargestFirstUtxoSelectionStrategy(backendService.utxoService))
.complete()
if (!result.isSuccessful) {
// Check if it's insufficient funds
if (result.response?.contains("insufficient", ignoreCase = true) == true ||
result.response?.contains("not enough", ignoreCase = true) == true) {
throw TransactionBuildError.InsufficientFunds
}
throw TransactionBuildError.BuildFailed(result.response ?: "Unknown error")
}
val signedTx = result.value
val txBytes = signedTx.serialize()
val txHash = signedTx.transactionId
// Calculate fee from tx body
val fee = signedTx.body.fee.toLong()
// Calculate totals
val inputsTotal = signedTx.body.inputs.sumOf { input ->
utxos.find { it.txHash == input.transactionId && it.outputIndex == input.index }
?.amount?.find { it.unit == "lovelace" }?.quantity?.toLongOrNull() ?: 0L
}
val outputsTotal = signedTx.body.outputs.sumOf { output ->
output.value.coin.toLong()
}
val changeAmount = outputsTotal - amountLovelace
BuiltTransaction(
txCbor = txBytes,
txHash = txHash,
fee = fee,
inputsLovelace = inputsTotal,
outputsLovelace = outputsTotal,
changeLovelace = changeAmount,
)
}
}
override suspend fun estimateFee(
senderAddress: String,
recipientAddress: String,
amountLovelace: Long,
): Result<Long> = withContext(Dispatchers.IO) {
runCatching {
// Get protocol parameters
val params = blockfrostClient.getProtocolParameters().getOrThrow()
// Estimate tx size (typical simple payment is ~250-350 bytes)
val estimatedSize = 350
// fee = a * size + b
val fee = params.minFeeA * estimatedSize + params.minFeeB
fee
}
}
private fun validateAddress(address: String) {
if (!address.startsWith("addr1") && !address.startsWith("addr_test1")) {
throw TransactionBuildError.InvalidAddress(address)
}
try {
AddressProvider.getAddress(address)
} catch (e: Exception) {
throw TransactionBuildError.InvalidAddress(address)
}
}
}
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/CardanoWalletManager.kt
package io.element.android.features.wallet.impl.cardano
import io.element.android.features.wallet.api.WalletState
import io.element.android.features.wallet.impl.storage.CardanoKeyStorage
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
class CardanoWalletManager @Inject constructor(
private val keyStorage: CardanoKeyStorage,
private val blockfrostClient: BlockfrostClient,
private val transactionBuilder: TransactionBuilder,
) {
private val _walletState = MutableStateFlow(WalletState(
hasWallet = false,
address = null,
balanceLovelace = null,
balanceAda = null,
isLoading = true,
error = null,
))
val walletState: StateFlow<WalletState> = _walletState.asStateFlow()
suspend fun initialize(sessionId: SessionId) {
_walletState.value = _walletState.value.copy(isLoading = true, error = null)
val hasWallet = keyStorage.hasWallet(sessionId)
if (hasWallet) {
val address = keyStorage.getBaseAddress(sessionId).getOrNull()
_walletState.value = _walletState.value.copy(
hasWallet = true,
address = address,
isLoading = false,
)
// Fetch balance in background
address?.let { refreshBalance(it) }
} else {
_walletState.value = _walletState.value.copy(
hasWallet = false,
address = null,
isLoading = false,
)
}
}
suspend fun refreshBalance(address: String) {
val result = blockfrostClient.getBalance(address)
result.onSuccess { lovelace ->
_walletState.value = _walletState.value.copy(
balanceLovelace = lovelace,
balanceAda = formatAda(lovelace),
error = null,
)
}.onFailure { error ->
_walletState.value = _walletState.value.copy(
error = error.message,
)
}
}
suspend fun sendPayment(
sessionId: SessionId,
recipientAddress: String,
amountLovelace: Long,
): Result<String> {
val mnemonic = keyStorage.getMnemonic(sessionId).getOrElse {
return Result.failure(it)
}
val senderAddress = keyStorage.getBaseAddress(sessionId).getOrElse {
return Result.failure(it)
}
// Build transaction
val builtTx = transactionBuilder.buildPayment(
senderAddress = senderAddress,
recipientAddress = recipientAddress,
amountLovelace = amountLovelace,
mnemonic = mnemonic,
).getOrElse {
return Result.failure(it)
}
// Submit transaction
val txHash = blockfrostClient.submitTransaction(builtTx.txCbor).getOrElse {
return Result.failure(it)
}
// Refresh balance
refreshBalance(senderAddress)
return Result.success(txHash)
}
private fun formatAda(lovelace: Long): String {
val ada = lovelace / 1_000_000.0
return "%.6f".format(ada).trimEnd('0').trimEnd('.')
}
}
Key Implementation Details
- Coin selection: Use
LargestFirstUtxoSelectionStrategyfrom cardano-client-lib- Selects largest UTXOs first to minimize inputs
- Handles multi-UTXO scenarios automatically
- QuickTx API: High-level builder handles fee calculation, change output, serialization
- Signing:
SignerProviders.signerFrom(account)signs with derived spending key - Fee calculation: Automatic based on protocol parameters and tx size
- Change output: Library adds change output automatically to sender address
Gotchas
- Min UTXO value: Outputs must be ≥1 ADA to avoid "UTxO too small" error
- Insufficient funds edge case: When amount + fee ≈ total balance, may fail to build change output
- UTXO exhaustion: After many small txs, may have many dust UTXOs. Consider UTXO consolidation later.
- TTL (time-to-live): Transactions expire after ~2 hours by default. User should be warned if tx isn't submitted quickly.
- Memory security: Zero out mnemonic array after building tx:
val mnemonicBytes = mnemonic.toByteArray() try { ... } finally { mnemonicBytes.fill(0) }
Task 5: /pay Slash Command Parser + SuggestionsProcessor Extension
Blocks: Task 6 (Payment Flow UI)
Blocked by: Task 1
Effort: 2 days
Acceptance criteria:
- Typing
/shows "pay" as suggestion /paysuggestion shows helpful description- Selecting
/payauto-completes to/pay /pay 10 ADA @jacobparses correctly/pay 10 ADA addr1q...parses correctly- Invalid syntax surfaces clear error
- Pressing send with
/pay ...intercepts and opens payment flow - User can cancel and return to composer
Files
New: features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/slash/SlashCommand.kt
package io.element.android.features.wallet.api.slash
import io.element.android.libraries.matrix.api.core.UserId
sealed interface SlashCommand {
data class Pay(
val amount: Double,
val unit: PaymentUnit,
val recipient: PaymentRecipient,
) : SlashCommand
}
enum class PaymentUnit(val symbol: String, val lovelaceMultiplier: Long) {
ADA("ADA", 1_000_000L),
LOVELACE("lovelace", 1L),
}
sealed interface PaymentRecipient {
data class MatrixUser(val userId: UserId) : PaymentRecipient
data class CardanoAddress(val address: String) : PaymentRecipient
}
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/slash/SlashCommandParser.kt
package io.element.android.features.wallet.impl.slash
import io.element.android.features.wallet.api.slash.PaymentRecipient
import io.element.android.features.wallet.api.slash.PaymentUnit
import io.element.android.features.wallet.api.slash.SlashCommand
import io.element.android.libraries.matrix.api.core.UserId
import javax.inject.Inject
class SlashCommandParser @Inject constructor() {
companion object {
// /pay <amount> <unit> <recipient>
// e.g., /pay 10 ADA @user:matrix.org
// e.g., /pay 5.5 ADA addr1q...
private val PAY_REGEX = Regex(
"""^/pay\s+(\d+(?:\.\d+)?)\s+(\w+)\s+(.+)$""",
RegexOption.IGNORE_CASE
)
// Cardano mainnet address prefix
private val CARDANO_ADDRESS_REGEX = Regex("""^addr1[a-z0-9]+$""", RegexOption.IGNORE_CASE)
}
sealed class ParseResult {
data class Success(val command: SlashCommand) : ParseResult()
data class Error(val message: String) : ParseResult()
object NotACommand : ParseResult()
}
fun parse(input: String): ParseResult {
val trimmed = input.trim()
if (!trimmed.startsWith("/")) {
return ParseResult.NotACommand
}
if (!trimmed.startsWith("/pay", ignoreCase = true)) {
return ParseResult.NotACommand
}
val match = PAY_REGEX.matchEntire(trimmed)
?: return ParseResult.Error("Invalid format. Use: /pay <amount> <unit> <@user or addr1...>")
val (amountStr, unitStr, recipientStr) = match.destructured
// Parse amount
val amount = amountStr.toDoubleOrNull()
?: return ParseResult.Error("Invalid amount: $amountStr")
if (amount <= 0) {
return ParseResult.Error("Amount must be positive")
}
// Parse unit
val unit = when (unitStr.uppercase()) {
"ADA" -> PaymentUnit.ADA
"LOVELACE" -> PaymentUnit.LOVELACE
else -> return ParseResult.Error("Invalid unit: $unitStr. Use ADA or lovelace")
}
// Parse recipient
val recipient = parseRecipient(recipientStr.trim())
?: return ParseResult.Error("Invalid recipient. Use @user:server or addr1...")
return ParseResult.Success(SlashCommand.Pay(
amount = amount,
unit = unit,
recipient = recipient,
))
}
private fun parseRecipient(input: String): PaymentRecipient? {
return when {
// Matrix user ID
input.startsWith("@") -> {
try {
PaymentRecipient.MatrixUser(UserId(input))
} catch (e: Exception) {
null
}
}
// Cardano address
CARDANO_ADDRESS_REGEX.matches(input) -> {
PaymentRecipient.CardanoAddress(input)
}
else -> null
}
}
fun isPartialPayCommand(input: String): Boolean {
val trimmed = input.trim().lowercase()
return trimmed.startsWith("/pay") || "/pay".startsWith(trimmed.ifEmpty { "/" })
}
}
Modify: libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt
Add new type:
// Add to sealed interface ResolvedSuggestion:
data class Command(
val command: String,
val description: String,
) : ResolvedSuggestion
Full diff:
sealed interface ResolvedSuggestion {
data class Member(...) : ResolvedSuggestion
data class Alias(...) : ResolvedSuggestion
+ data class Command(
+ val command: String,
+ val description: String,
+ ) : ResolvedSuggestion
}
Modify: features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt
Replace empty command handling:
SuggestionType.Command,
-SuggestionType.Emoji,
-is SuggestionType.Custom -> {
- // Clear suggestions
- emptyList()
-}
+-> {
+ val commands = listOf(
+ ResolvedSuggestion.Command("/pay", "Send ADA to someone"),
+ )
+ commands.filter {
+ it.command.contains(suggestion.text, ignoreCase = true)
+ }
+}
+SuggestionType.Emoji,
+is SuggestionType.Custom -> {
+ emptyList()
+}
Modify: features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
Add slash command interception in sendMessage:
// Add import at top:
import io.element.android.features.wallet.impl.slash.SlashCommandParser
// Inject parser:
@Inject
constructor(
// ... existing params ...
private val slashCommandParser: SlashCommandParser,
)
// In handleEvents(), find AnalyticsEvents.Composer.SendMessage handling:
// BEFORE sending to timeline, add:
when (val parseResult = slashCommandParser.parse(message.markdown)) {
is SlashCommandParser.ParseResult.Success -> {
when (val command = parseResult.command) {
is SlashCommand.Pay -> {
// Navigate to payment flow instead of sending
navigator.navigateToPaymentFlow(
roomId = room.roomId.value,
amount = command.amount,
unit = command.unit,
recipient = command.recipient,
)
return@launch
}
}
}
is SlashCommandParser.ParseResult.Error -> {
// Show error toast/snackbar
_state.value = _state.value.copy(
snackbarMessage = parseResult.message
)
return@launch
}
SlashCommandParser.ParseResult.NotACommand -> {
// Continue normal send flow
}
}
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/slash/RecipientAddressResolver.kt
package io.element.android.features.wallet.impl.slash
import io.element.android.features.wallet.api.slash.PaymentRecipient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import javax.inject.Inject
/**
* Resolves a Matrix user to a Cardano address.
*
* Phase 1: Returns null for Matrix users (address must be entered manually)
* Phase 2: Will query user's account data for published Cardano address
*/
class RecipientAddressResolver @Inject constructor() {
sealed class ResolveResult {
data class Resolved(val address: String) : ResolveResult()
object NeedsManualEntry : ResolveResult()
data class Error(val message: String) : ResolveResult()
}
suspend fun resolve(
recipient: PaymentRecipient,
room: MatrixRoom,
): ResolveResult {
return when (recipient) {
is PaymentRecipient.CardanoAddress -> {
// Validate address format
if (isValidCardanoAddress(recipient.address)) {
ResolveResult.Resolved(recipient.address)
} else {
ResolveResult.Error("Invalid Cardano address format")
}
}
is PaymentRecipient.MatrixUser -> {
// Phase 1: We can't resolve Matrix user to Cardano address
// User will need to enter address manually in payment UI
ResolveResult.NeedsManualEntry
}
}
}
private fun isValidCardanoAddress(address: String): Boolean {
// Basic validation: mainnet addresses start with addr1
// More thorough validation happens in TransactionBuilder
return address.startsWith("addr1") && address.length >= 50
}
}
Key Implementation Details
-
Syntax:
/pay <amount> <unit> <recipient>- Amount: decimal number (e.g.,
10,5.5) - Unit:
ADAorlovelace(case-insensitive) - Recipient:
@user:serveroraddr1...
- Amount: decimal number (e.g.,
-
Suggestions: When user types
/, show "pay" as autocomplete option -
Interception point: In
MessageComposerPresenter.sendMessage(), check if message is a slash command BEFORE sending to timeline -
Matrix user resolution: Phase 1 doesn't resolve
@userto address — payment UI will prompt for address entry
Gotchas
- Partial commands:
/pashouldn't error — user is still typing - Case sensitivity: Commands should be case-insensitive (
/PAY=/pay) - Whitespace: Handle multiple spaces between tokens
- Address validation: Basic format check in parser; full validation in TransactionBuilder
- Room context: Payment requires room context to resolve @mentions to actual user IDs
- Edit prevention: Don't allow editing messages that were slash commands (they didn't actually send)
Task 6: Payment Flow UI
Blocks: Task 7 (Payment Card)
Blocked by: Tasks 4, 5
Effort: 3 days
Acceptance criteria:
- Confirmation screen shows recipient, amount, fee estimate
- Manual address entry field when recipient is @user
- "Confirm" triggers biometric authentication
- Success state shows tx hash
- Error states display appropriate messages
- "Cancel" returns to composer
- Loading state during tx submission
- All states match Element X design patterns
Files
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentFlowState.kt
package io.element.android.features.wallet.impl.payment
import io.element.android.features.wallet.api.slash.PaymentRecipient
import io.element.android.features.wallet.api.slash.PaymentUnit
data class PaymentFlowState(
val step: PaymentStep,
val amount: Double,
val unit: PaymentUnit,
val originalRecipient: PaymentRecipient,
val resolvedAddress: String?,
val addressInput: String,
val estimatedFee: Long?,
val estimatedFeeAda: String?,
val totalAmountAda: String?,
val senderAddress: String?,
val senderBalance: Long?,
val senderBalanceAda: String?,
val txHash: String?,
val error: PaymentError?,
val eventActions: PaymentFlowEvents,
)
sealed interface PaymentStep {
object Loading : PaymentStep
object EnterAddress : PaymentStep // When @user can't be resolved
object Confirm : PaymentStep
object Authenticating : PaymentStep
object Submitting : PaymentStep
object Success : PaymentStep
object Error : PaymentStep
}
sealed interface PaymentError {
object InsufficientFunds : PaymentError
object InvalidAddress : PaymentError
object NetworkError : PaymentError
object AuthenticationFailed : PaymentError
object AuthenticationCancelled : PaymentError
data class TransactionFailed(val message: String) : PaymentError
}
interface PaymentFlowEvents {
fun onAddressChanged(address: String)
fun onConfirmAddress()
fun onConfirmPayment()
fun onAuthenticationResult(success: Boolean, error: String?)
fun onCancel()
fun onDismissError()
fun onDone()
}
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentFlowPresenter.kt
package io.element.android.features.wallet.impl.payment
import androidx.compose.runtime.*
import com.bloxbean.cardano.client.common.model.Networks
import io.element.android.features.wallet.api.slash.PaymentRecipient
import io.element.android.features.wallet.api.slash.PaymentUnit
import io.element.android.features.wallet.impl.biometric.BiometricAuthenticator
import io.element.android.features.wallet.impl.cardano.BlockfrostClient
import io.element.android.features.wallet.impl.cardano.CardanoWalletManager
import io.element.android.features.wallet.impl.cardano.TransactionBuildError
import io.element.android.features.wallet.impl.cardano.TransactionBuilder
import io.element.android.features.wallet.impl.storage.CardanoKeyStorage
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.launch
import javax.inject.Inject
class PaymentFlowPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val walletManager: CardanoWalletManager,
private val keyStorage: CardanoKeyStorage,
private val transactionBuilder: TransactionBuilder,
private val blockfrostClient: BlockfrostClient,
) : Presenter<PaymentFlowState> {
private var amount: Double = 0.0
private var unit: PaymentUnit = PaymentUnit.ADA
private var originalRecipient: PaymentRecipient? = null
private var roomId: String? = null
fun initialize(
roomId: String,
amount: Double,
unit: PaymentUnit,
recipient: PaymentRecipient,
) {
this.roomId = roomId
this.amount = amount
this.unit = unit
this.originalRecipient = recipient
}
@Composable
override fun present(): PaymentFlowState {
val scope = rememberCoroutineScope()
val sessionId = matrixClient.sessionId
var step by remember { mutableStateOf<PaymentStep>(PaymentStep.Loading) }
var resolvedAddress by remember { mutableStateOf<String?>(null) }
var addressInput by remember { mutableStateOf("") }
var estimatedFee by remember { mutableStateOf<Long?>(null) }
var senderAddress by remember { mutableStateOf<String?>(null) }
var senderBalance by remember { mutableStateOf<Long?>(null) }
var txHash by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<PaymentError?>(null) }
// Initialize
LaunchedEffect(Unit) {
// Get sender address
val address = keyStorage.getBaseAddress(sessionId).getOrNull()
senderAddress = address
// Get balance
address?.let {
blockfrostClient.getBalance(it).onSuccess { balance ->
senderBalance = balance
}
}
// Check recipient type
when (val recipient = originalRecipient) {
is PaymentRecipient.CardanoAddress -> {
resolvedAddress = recipient.address
addressInput = recipient.address
step = PaymentStep.Confirm
}
is PaymentRecipient.MatrixUser -> {
// Can't resolve in Phase 1 — need manual entry
step = PaymentStep.EnterAddress
}
null -> {
step = PaymentStep.EnterAddress
}
}
// Estimate fee
val recipientAddr = resolvedAddress ?: "addr1qxck..." // dummy for estimation
transactionBuilder.estimateFee(
senderAddress = address ?: return@LaunchedEffect,
recipientAddress = recipientAddr,
amountLovelace = (amount * unit.lovelaceMultiplier).toLong(),
).onSuccess { fee ->
estimatedFee = fee
}
}
val amountLovelace = (amount * unit.lovelaceMultiplier).toLong()
val amountAda = amountLovelace / 1_000_000.0
val feeAda = estimatedFee?.let { it / 1_000_000.0 }
val totalAda = feeAda?.let { amountAda + it }
val events = remember {
object : PaymentFlowEvents {
override fun onAddressChanged(address: String) {
addressInput = address
}
override fun onConfirmAddress() {
if (isValidCardanoAddress(addressInput)) {
resolvedAddress = addressInput
step = PaymentStep.Confirm
} else {
error = PaymentError.InvalidAddress
}
}
override fun onConfirmPayment() {
step = PaymentStep.Authenticating
}
override fun onAuthenticationResult(success: Boolean, errorMsg: String?) {
if (success) {
step = PaymentStep.Submitting
scope.launch {
submitTransaction(
sessionId = sessionId,
recipientAddress = resolvedAddress!!,
amountLovelace = amountLovelace,
onSuccess = { hash ->
txHash = hash
step = PaymentStep.Success
},
onError = { err ->
error = err
step = PaymentStep.Error
}
)
}
} else {
error = if (errorMsg?.contains("cancel", ignoreCase = true) == true) {
PaymentError.AuthenticationCancelled
} else {
PaymentError.AuthenticationFailed
}
step = PaymentStep.Error
}
}
override fun onCancel() {
// Navigator handles back
}
override fun onDismissError() {
error = null
step = PaymentStep.Confirm
}
override fun onDone() {
// Navigator handles finish
}
}
}
return PaymentFlowState(
step = step,
amount = amount,
unit = unit,
originalRecipient = originalRecipient ?: PaymentRecipient.CardanoAddress(""),
resolvedAddress = resolvedAddress,
addressInput = addressInput,
estimatedFee = estimatedFee,
estimatedFeeAda = feeAda?.let { "%.6f".format(it) },
totalAmountAda = totalAda?.let { "%.6f".format(it) },
senderAddress = senderAddress,
senderBalance = senderBalance,
senderBalanceAda = senderBalance?.let { "%.6f".format(it / 1_000_000.0) },
txHash = txHash,
error = error,
eventActions = events,
)
}
private suspend fun submitTransaction(
sessionId: io.element.android.libraries.matrix.api.core.SessionId,
recipientAddress: String,
amountLovelace: Long,
onSuccess: (String) -> Unit,
onError: (PaymentError) -> Unit,
) {
val result = walletManager.sendPayment(
sessionId = sessionId,
recipientAddress = recipientAddress,
amountLovelace = amountLovelace,
)
result.onSuccess { hash ->
// Send Matrix event
sendPaymentEvent(
txHash = hash,
recipientAddress = recipientAddress,
amountLovelace = amountLovelace,
)
onSuccess(hash)
}.onFailure { throwable ->
val paymentError = when (throwable) {
is TransactionBuildError.InsufficientFunds -> PaymentError.InsufficientFunds
is TransactionBuildError.InvalidAddress -> PaymentError.InvalidAddress
is TransactionBuildError.NoUtxosAvailable -> PaymentError.InsufficientFunds
else -> PaymentError.TransactionFailed(throwable.message ?: "Unknown error")
}
onError(paymentError)
}
}
private suspend fun sendPaymentEvent(
txHash: String,
recipientAddress: String,
amountLovelace: Long,
) {
// Send m.payment.cardano event to room
val room = matrixClient.getRoom(io.element.android.libraries.matrix.api.core.RoomId(roomId!!))
room?.let {
// This will be implemented in Task 7
// it.sendPaymentEvent(txHash, recipientAddress, amountLovelace)
}
}
private fun isValidCardanoAddress(address: String): Boolean {
return address.startsWith("addr1") && address.length >= 50
}
}
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentFlowScreen.kt
package io.element.android.features.wallet.impl.payment
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.theme.components.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaymentFlowScreen(
state: PaymentFlowState,
onNavigateBack: () -> Unit,
onAuthenticateRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Send Payment") },
navigationIcon = {
BackButton(onClick = {
state.eventActions.onCancel()
onNavigateBack()
})
}
)
},
modifier = modifier,
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
when (state.step) {
PaymentStep.Loading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
PaymentStep.EnterAddress -> {
EnterAddressContent(
state = state,
modifier = Modifier.fillMaxSize(),
)
}
PaymentStep.Confirm -> {
ConfirmContent(
state = state,
onConfirm = {
state.eventActions.onConfirmPayment()
onAuthenticateRequest()
},
modifier = Modifier.fillMaxSize(),
)
}
PaymentStep.Authenticating -> {
AuthenticatingContent(
modifier = Modifier.fillMaxSize(),
)
}
PaymentStep.Submitting -> {
SubmittingContent(
modifier = Modifier.fillMaxSize(),
)
}
PaymentStep.Success -> {
SuccessContent(
txHash = state.txHash ?: "",
onDone = {
state.eventActions.onDone()
onNavigateBack()
},
modifier = Modifier.fillMaxSize(),
)
}
PaymentStep.Error -> {
ErrorContent(
error = state.error,
onRetry = { state.eventActions.onDismissError() },
onCancel = {
state.eventActions.onCancel()
onNavigateBack()
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
}
@Composable
private fun EnterAddressContent(
state: PaymentFlowState,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Enter recipient's Cardano address",
style = MaterialTheme.typography.titleMedium,
)
when (val recipient = state.originalRecipient) {
is io.element.android.features.wallet.api.slash.PaymentRecipient.MatrixUser -> {
Text(
text = "Sending to ${recipient.userId.value}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Enter their Cardano address below:",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> {}
}
OutlinedTextField(
value = state.addressInput,
onValueChange = { state.eventActions.onAddressChanged(it) },
label = { Text("Cardano Address") },
placeholder = { Text("addr1q...") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = state.error == PaymentError.InvalidAddress,
supportingText = if (state.error == PaymentError.InvalidAddress) {
{ Text("Invalid Cardano address") }
} else null,
)
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = { state.eventActions.onConfirmAddress() },
modifier = Modifier.fillMaxWidth(),
enabled = state.addressInput.isNotBlank(),
) {
Text("Continue")
}
}
}
@Composable
private fun ConfirmContent(
state: PaymentFlowState,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Amount card
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Amount",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "${state.amount} ${state.unit.symbol}",
style = MaterialTheme.typography.headlineLarge,
)
}
}
// Details
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
DetailRow("To", state.resolvedAddress?.take(20) + "..." ?: "Unknown")
DetailRow("Network Fee", state.estimatedFeeAda?.let { "~$it ADA" } ?: "Calculating...")
Divider()
DetailRow(
"Total",
state.totalAmountAda?.let { "$it ADA" } ?: "Calculating...",
bold = true
)
}
}
// Balance warning
state.senderBalance?.let { balance ->
val amountLovelace = (state.amount * state.unit.lovelaceMultiplier).toLong()
val feeEstimate = state.estimatedFee ?: 200_000L
if (balance < amountLovelace + feeEstimate) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "⚠️ Insufficient balance (${state.senderBalanceAda} ADA available)",
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = onConfirm,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.Send, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Confirm & Authenticate")
}
}
}
@Composable
private fun DetailRow(
label: String,
value: String,
bold: Boolean = false,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = label,
style = if (bold) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium,
)
Text(
text = value,
style = if (bold) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium,
)
}
}
@Composable
private fun AuthenticatingContent(modifier: Modifier = Modifier) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("Authenticate to continue...")
}
}
}
@Composable
private fun SubmittingContent(modifier: Modifier = Modifier) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("Submitting transaction...")
}
}
}
@Composable
private fun SuccessContent(
txHash: String,
onDone: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(64.dp),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Payment Sent!",
style = MaterialTheme.typography.headlineMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Transaction: ${txHash.take(16)}...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onDone) {
Text("Done")
}
}
}
@Composable
private fun ErrorContent(
error: PaymentError?,
onRetry: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Default.Close,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(64.dp),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Payment Failed",
style = MaterialTheme.typography.headlineMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = when (error) {
PaymentError.InsufficientFunds -> "Not enough ADA in your wallet"
PaymentError.InvalidAddress -> "Invalid recipient address"
PaymentError.NetworkError -> "Network error. Please try again."
PaymentError.AuthenticationFailed -> "Authentication failed"
PaymentError.AuthenticationCancelled -> "Authentication cancelled"
is PaymentError.TransactionFailed -> error.message
null -> "Unknown error"
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedButton(onClick = onCancel) {
Text("Cancel")
}
Button(onClick = onRetry) {
Text("Try Again")
}
}
}
}
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentFlowNode.kt
package io.element.android.features.wallet.impl.payment
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.fragment.app.FragmentActivity
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.wallet.api.slash.PaymentRecipient
import io.element.android.features.wallet.api.slash.PaymentUnit
import io.element.android.features.wallet.impl.biometric.BiometricAuthenticator
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.launch
@ContributesNode(SessionScope::class)
class PaymentFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: PaymentFlowPresenter,
private val biometricAuthenticator: BiometricAuthenticator,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val roomId: String,
val amount: Double,
val unit: PaymentUnit,
val recipient: PaymentRecipient,
) : NodeInputs
private val inputs: Inputs = inputs()
init {
presenter.initialize(
roomId = inputs.roomId,
amount = inputs.amount,
unit = inputs.unit,
recipient = inputs.recipient,
)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
PaymentFlowScreen(
state = state,
onNavigateBack = { navigateUp() },
onAuthenticateRequest = {
lifecycleScope.launch {
val activity = requireActivity() as FragmentActivity
val result = biometricAuthenticator.authenticate(
activity = activity,
title = "Confirm Payment",
subtitle = "Authenticate to send ${state.amount} ${state.unit.symbol}",
)
when (result) {
BiometricAuthenticator.AuthResult.Success -> {
state.eventActions.onAuthenticationResult(true, null)
}
is BiometricAuthenticator.AuthResult.Error -> {
state.eventActions.onAuthenticationResult(false, result.message)
}
BiometricAuthenticator.AuthResult.Cancelled -> {
state.eventActions.onAuthenticationResult(false, "cancelled")
}
}
}
},
modifier = modifier,
)
}
@AssistedFactory
interface Factory {
fun create(
buildContext: BuildContext,
plugins: List<Plugin>,
): PaymentFlowNode
}
}
Key Implementation Details
- State machine: Loading → EnterAddress (if needed) → Confirm → Authenticating → Submitting → Success/Error
- Biometric trigger: Called from Node when entering
Authenticatingstep - Presenter pattern: Follows Element X conventions with
@Composable present()returning state - Node pattern: Uses Appyx for navigation,
@ContributesNodefor DI
Gotchas
- Activity context for biometrics: Need
FragmentActivityfrom 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.cardanoevent sends to room- Payment card renders for sender
- Payment card renders for recipient
- Card shows amount, status, explorer link
- Tapping explorer link opens CardanoScan
- PENDING status updates to CONFIRMED (polling)
- Unknown/old clients show fallback text
Files
New: features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/timeline/PaymentEventContent.kt
package io.element.android.features.wallet.api.timeline
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Content for m.payment.cardano events.
*
* Example JSON:
* {
* "msgtype": "m.payment.cardano",
* "body": "Sent 10 ADA",
* "chain": "cardano",
* "network": "mainnet",
* "tx_hash": "abc123...",
* "sender_address": "addr1q...",
* "recipient_address": "addr1q...",
* "amount_lovelace": "10000000",
* "status": "pending"
* }
*/
@Serializable
data class PaymentEventContent(
@SerialName("msgtype")
val msgtype: String = "m.payment.cardano",
@SerialName("body")
val body: String, // Fallback text for clients that don't support this
@SerialName("chain")
val chain: String = "cardano",
@SerialName("network")
val network: String = "mainnet",
@SerialName("tx_hash")
val txHash: String?,
@SerialName("sender_address")
val senderAddress: String,
@SerialName("recipient_address")
val recipientAddress: String,
@SerialName("amount_lovelace")
val amountLovelace: String, // String to avoid precision issues
@SerialName("status")
val status: String, // "pending", "confirmed", "failed"
)
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemPaymentContent.kt
package io.element.android.features.wallet.impl.timeline
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
data class TimelineItemPaymentContent(
val txHash: String?,
val senderAddress: String,
val recipientAddress: String,
val amountLovelace: Long,
val amountAda: String,
val status: PaymentStatus,
val network: String,
val isMine: Boolean, // Did current user send this?
) : TimelineItemEventContent {
override val type: String = "m.payment.cardano"
}
enum class PaymentStatus {
PENDING,
CONFIRMED,
FAILED,
}
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemPaymentView.kt
package io.element.android.features.wallet.impl.timeline
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowOutward
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun TimelineItemPaymentView(
content: TimelineItemPaymentContent,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
Card(
modifier = modifier.widthIn(max = 280.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
),
) {
Column(
modifier = Modifier.padding(12.dp),
) {
// Header row
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Cardano logo placeholder
Text(
text = "₳",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = if (content.isMine) "Payment Sent" else "Payment Received",
style = MaterialTheme.typography.labelLarge,
)
Spacer(modifier = Modifier.weight(1f))
// Status icon
when (content.status) {
PaymentStatus.PENDING -> Icon(
Icons.Default.Schedule,
contentDescription = "Pending",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
PaymentStatus.CONFIRMED -> Icon(
Icons.Default.CheckCircle,
contentDescription = "Confirmed",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
PaymentStatus.FAILED -> Icon(
Icons.Default.Error,
contentDescription = "Failed",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Amount
Text(
text = "${content.amountAda} ADA",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(4.dp))
// Address (truncated)
Text(
text = if (content.isMine) {
"To: ${content.recipientAddress.take(12)}...${content.recipientAddress.takeLast(8)}"
} else {
"From: ${content.senderAddress.take(12)}...${content.senderAddress.takeLast(8)}"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Explorer link
content.txHash?.let { hash ->
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.clickable {
val url = "https://cardanoscan.io/transaction/$hash"
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "View on CardanoScan",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
Icon(
Icons.Default.ArrowOutward,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(14.dp),
)
}
}
}
}
}
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemPaymentFactory.kt
package io.element.android.features.wallet.impl.timeline
import io.element.android.features.wallet.api.timeline.PaymentEventContent
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Inject
class TimelineItemPaymentFactory @Inject constructor(
private val json: Json,
) {
fun create(
rawContent: JsonObject,
currentUserId: UserId,
senderUserId: UserId,
): TimelineItemPaymentContent? {
return try {
val msgtype = rawContent["msgtype"]?.jsonPrimitive?.content
if (msgtype != "m.payment.cardano") return null
val content = json.decodeFromJsonElement(
PaymentEventContent.serializer(),
rawContent
)
val amountLovelace = content.amountLovelace.toLongOrNull() ?: return null
val amountAda = "%.6f".format(amountLovelace / 1_000_000.0)
.trimEnd('0')
.trimEnd('.')
val status = when (content.status.lowercase()) {
"pending" -> PaymentStatus.PENDING
"confirmed" -> PaymentStatus.CONFIRMED
"failed" -> PaymentStatus.FAILED
else -> PaymentStatus.PENDING
}
TimelineItemPaymentContent(
txHash = content.txHash,
senderAddress = content.senderAddress,
recipientAddress = content.recipientAddress,
amountLovelace = amountLovelace,
amountAda = amountAda,
status = status,
network = content.network,
isMine = senderUserId == currentUserId,
)
} catch (e: Exception) {
null
}
}
}
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/PaymentStatusPoller.kt
package io.element.android.features.wallet.impl.timeline
import io.element.android.features.wallet.impl.cardano.BlockfrostClient
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
/**
* Polls Blockfrost to update payment status from PENDING to CONFIRMED.
* Updates the Matrix event when status changes.
*/
class PaymentStatusPoller @Inject constructor(
private val blockfrostClient: BlockfrostClient,
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _pendingPayments = MutableStateFlow<Set<String>>(emptySet())
val pendingPayments: StateFlow<Set<String>> = _pendingPayments
fun startPolling(
txHash: String,
eventId: String,
room: MatrixRoom,
) {
_pendingPayments.value = _pendingPayments.value + txHash
scope.launch {
var confirmed = false
var attempts = 0
val maxAttempts = 60 // Poll for ~10 minutes
while (!confirmed && attempts < maxAttempts) {
delay(10_000) // 10 seconds between polls
attempts++
val result = blockfrostClient.getTransactionStatus(txHash)
result.onSuccess { status ->
if (status.confirmed) {
confirmed = true
_pendingPayments.value = _pendingPayments.value - txHash
// Update Matrix event with confirmed status
// Note: This requires the ability to edit events or send a relation
// For MVP, we might just update local state
}
}
}
if (!confirmed) {
_pendingPayments.value = _pendingPayments.value - txHash
}
}
}
fun stopAll() {
scope.coroutineContext.cancelChildren()
_pendingPayments.value = emptySet()
}
}
Modify: features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt
Add payment event handling:
// Add import:
import io.element.android.features.wallet.impl.timeline.TimelineItemPaymentFactory
import io.element.android.features.wallet.impl.timeline.TimelineItemPaymentContent
// Inject factory:
@Inject constructor(
// ... existing ...
private val paymentFactory: TimelineItemPaymentFactory,
)
// In create() method, add case for raw JSON content handling:
// When content is UnknownContent, try to parse as payment:
is UnknownContent -> {
// Try payment first
val rawJson = /* get raw JSON from event */
paymentFactory.create(rawJson, currentUserId, senderUserId)
?: TimelineItemUnknownContent
}
Modify: features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt
Add payment case:
// Add import:
import io.element.android.features.wallet.impl.timeline.TimelineItemPaymentContent
import io.element.android.features.wallet.impl.timeline.TimelineItemPaymentView
// In the when block:
is TimelineItemPaymentContent -> TimelineItemPaymentView(
content = content,
modifier = modifier,
)
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/room/PaymentEventSender.kt
package io.element.android.features.wallet.impl.room
import io.element.android.features.wallet.api.timeline.PaymentEventContent
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject
class PaymentEventSender @Inject constructor(
private val json: Json,
) {
suspend fun sendPaymentEvent(
room: MatrixRoom,
txHash: String,
senderAddress: String,
recipientAddress: String,
amountLovelace: Long,
): Result<String> {
val content = PaymentEventContent(
body = "Sent ${amountLovelace / 1_000_000.0} ADA",
txHash = txHash,
senderAddress = senderAddress,
recipientAddress = recipientAddress,
amountLovelace = amountLovelace.toString(),
status = "pending",
)
// Send as raw JSON event
// This uses the SDK's ability to send arbitrary message content
return room.sendMessage(
body = content.body,
htmlBody = null,
mentions = emptyList(),
).map { eventId ->
// TODO: Actually send as m.room.message with custom msgtype
// This requires SDK extension (Task 8)
eventId.value
}
}
}
Key Implementation Details
- Event schema: Custom
msgtypeofm.payment.cardanowith standardm.room.messageevent type - Fallback text:
bodyfield contains human-readable text for clients without payment support - Status tracking: Initially "pending", updated to "confirmed" via polling
- Explorer URL: CardanoScan for mainnet:
https://cardanoscan.io/transaction/{txHash} - 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
bodytext only. - Network switch:
networkfield 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.cardanomsgtype - 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:
- Intercept at the Kotlin layer: After SDK returns timeline items, check for messages with our custom msgtype
- Access raw content: The SDK does provide raw JSON for unknown content
- Custom parsing: Parse
m.payment.cardanocontent ourselves
New: features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/sdk/PaymentEventParser.kt
package io.element.android.features.wallet.impl.sdk
import io.element.android.features.wallet.api.timeline.PaymentEventContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Inject
class PaymentEventParser @Inject constructor(
private val json: Json,
) {
/**
* Checks if a message content is a payment event and parses it.
*/
fun tryParse(content: MessageContent): PaymentEventContent? {
// Check if it's an "other" message type (custom msgtype)
val messageType = content.type
if (messageType !is OtherMessageType) return null
// Check if msgtype matches
if (messageType.msgtype != "m.payment.cardano") return null
// Try to parse the raw content
return try {
// The SDK should expose raw content for OtherMessageType
// This might be in messageType.body or a raw JSON field
// Exact API depends on SDK version
// Attempt to parse from available data
val rawJson = messageType.rawContent ?: return null
json.decodeFromJsonElement(PaymentEventContent.serializer(), rawJson)
} catch (e: Exception) {
null
}
}
}
Modify: SDK Binding (if raw content not exposed)
If the SDK doesn't expose raw JSON for custom message types, we need a minimal extension.
Option A: Fork and patch matrix-rust-sdk-bindings
In crates/matrix-sdk-ffi/src/timeline/content.rs, add:
pub struct OtherMessageType {
pub msgtype: String,
pub body: String,
pub raw_content: Option<String>, // ADD THIS
}
Then regenerate Kotlin bindings.
Option B: Use m.room.message with formatted_body hack
Store payment JSON in formatted_body:
{
"msgtype": "m.text",
"body": "Sent 10 ADA",
"format": "io.element.payment.cardano",
"formatted_body": "{\"tx_hash\":\"...\", ...}"
}
This is hacky but works without SDK changes.
Option C: Send as State Event
Use a custom state event type instead of room message:
- Event type:
com.sulkta.payment - State key: transaction ID
Pro: Full JSON control
Con: State events aren't rendered in timeline by default
Recommended Approach for Phase 1
Use Option B (formatted_body hack) for MVP:
// PaymentEventSender.kt - Updated
suspend fun sendPaymentEvent(
room: MatrixRoom,
txHash: String,
senderAddress: String,
recipientAddress: String,
amountLovelace: Long,
): Result<String> {
val paymentJson = json.encodeToString(PaymentEventContent(
body = "Sent ${formatAda(amountLovelace)} ADA",
txHash = txHash,
senderAddress = senderAddress,
recipientAddress = recipientAddress,
amountLovelace = amountLovelace.toString(),
status = "pending",
))
// Send as text message with custom format marker
return room.sendMessage(
body = "Sent ${formatAda(amountLovelace)} ADA",
htmlBody = paymentJson, // Abuse formatted_body for JSON
format = "io.element.payment.cardano", // Custom format marker
mentions = emptyList(),
)
}
// TimelineItemPaymentFactory.kt - Updated parsing
fun create(content: MessageContent, ...): TimelineItemPaymentContent? {
// Check for our custom format
if (content.formattedBody != null &&
content.format == "io.element.payment.cardano") {
return try {
val paymentContent = json.decodeFromString<PaymentEventContent>(
content.formattedBody!!
)
// ... convert to TimelineItemPaymentContent
} catch (e: Exception) {
null
}
}
return null
}
Key Implementation Details
- MVP approach: Use
formatted_bodyto store JSON, with customformatmarker - Detection: Check
formatfield for our marker before parsing - Fallback:
bodycontains human-readable text for other clients - 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
- SSSS Integration: Store encrypted wallet seed in Matrix account data
- Cross-device sync: Restore wallet on new device after verification
- Recipient address lookup: Query recipient's published Cardano address from their Matrix profile
- Balance widget: Show wallet balance in room header
- Transaction history: Query and display past payments
- Multi-asset support: Handle native tokens
Document prepared for Element X ADA project. Ready for implementation.