3127 lines
108 KiB
Markdown
3127 lines
108 KiB
Markdown
# 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<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`
|
|
```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<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`
|
|
```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<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`
|
|
```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<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`
|
|
```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<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`
|
|
```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<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`
|
|
```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<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`
|
|
```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> = _walletState.asStateFlow()
|
|
|
|
suspend fun initialize(sessionId: SessionId) {
|
|
_walletState.value = _walletState.value.copy(isLoading = true, error = null)
|
|
|
|
val hasWallet = keyStorage.hasWallet(sessionId)
|
|
if (hasWallet) {
|
|
val address = keyStorage.getBaseAddress(sessionId).getOrNull()
|
|
_walletState.value = _walletState.value.copy(
|
|
hasWallet = true,
|
|
address = address,
|
|
isLoading = false,
|
|
)
|
|
|
|
// Fetch balance in background
|
|
address?.let { refreshBalance(it) }
|
|
} else {
|
|
_walletState.value = _walletState.value.copy(
|
|
hasWallet = false,
|
|
address = null,
|
|
isLoading = false,
|
|
)
|
|
}
|
|
}
|
|
|
|
suspend fun refreshBalance(address: String) {
|
|
val result = blockfrostClient.getBalance(address)
|
|
result.onSuccess { lovelace ->
|
|
_walletState.value = _walletState.value.copy(
|
|
balanceLovelace = lovelace,
|
|
balanceAda = formatAda(lovelace),
|
|
error = null,
|
|
)
|
|
}.onFailure { error ->
|
|
_walletState.value = _walletState.value.copy(
|
|
error = error.message,
|
|
)
|
|
}
|
|
}
|
|
|
|
suspend fun sendPayment(
|
|
sessionId: SessionId,
|
|
recipientAddress: String,
|
|
amountLovelace: Long,
|
|
): Result<String> {
|
|
val mnemonic = keyStorage.getMnemonic(sessionId).getOrElse {
|
|
return Result.failure(it)
|
|
}
|
|
|
|
val senderAddress = keyStorage.getBaseAddress(sessionId).getOrElse {
|
|
return Result.failure(it)
|
|
}
|
|
|
|
// Build transaction
|
|
val builtTx = transactionBuilder.buildPayment(
|
|
senderAddress = senderAddress,
|
|
recipientAddress = recipientAddress,
|
|
amountLovelace = amountLovelace,
|
|
mnemonic = mnemonic,
|
|
).getOrElse {
|
|
return Result.failure(it)
|
|
}
|
|
|
|
// Submit transaction
|
|
val txHash = blockfrostClient.submitTransaction(builtTx.txCbor).getOrElse {
|
|
return Result.failure(it)
|
|
}
|
|
|
|
// Refresh balance
|
|
refreshBalance(senderAddress)
|
|
|
|
return Result.success(txHash)
|
|
}
|
|
|
|
private fun formatAda(lovelace: Long): String {
|
|
val ada = lovelace / 1_000_000.0
|
|
return "%.6f".format(ada).trimEnd('0').trimEnd('.')
|
|
}
|
|
}
|
|
```
|
|
|
|
### Key Implementation Details
|
|
|
|
1. **Coin selection**: Use `LargestFirstUtxoSelectionStrategy` from cardano-client-lib
|
|
- Selects largest UTXOs first to minimize inputs
|
|
- Handles multi-UTXO scenarios automatically
|
|
2. **QuickTx API**: High-level builder handles fee calculation, change output, serialization
|
|
3. **Signing**: `SignerProviders.signerFrom(account)` signs with derived spending key
|
|
4. **Fee calculation**: Automatic based on protocol parameters and tx size
|
|
5. **Change output**: Library adds change output automatically to sender address
|
|
|
|
### Gotchas
|
|
|
|
- **Min UTXO value**: Outputs must be ≥1 ADA to avoid "UTxO too small" error
|
|
- **Insufficient funds edge case**: When amount + fee ≈ total balance, may fail to build change output
|
|
- **UTXO exhaustion**: After many small txs, may have many dust UTXOs. Consider UTXO consolidation later.
|
|
- **TTL (time-to-live)**: Transactions expire after ~2 hours by default. User should be warned if tx isn't submitted quickly.
|
|
- **Memory security**: Zero out mnemonic array after building tx:
|
|
```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 <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:
|
|
```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> <unit> <recipient>`
|
|
- Amount: decimal number (e.g., `10`, `5.5`)
|
|
- Unit: `ADA` or `lovelace` (case-insensitive)
|
|
- Recipient: `@user:server` or `addr1...`
|
|
|
|
2. **Suggestions**: When user types `/`, show "pay" as autocomplete option
|
|
|
|
3. **Interception point**: In `MessageComposerPresenter.sendMessage()`, check if message is a slash command BEFORE sending to timeline
|
|
|
|
4. **Matrix user resolution**: Phase 1 doesn't resolve `@user` to address — payment UI will prompt for address entry
|
|
|
|
### Gotchas
|
|
|
|
- **Partial commands**: `/pa` shouldn't error — user is still typing
|
|
- **Case sensitivity**: Commands should be case-insensitive (`/PAY` = `/pay`)
|
|
- **Whitespace**: Handle multiple spaces between tokens
|
|
- **Address validation**: Basic format check in parser; full validation in TransactionBuilder
|
|
- **Room context**: Payment requires room context to resolve @mentions to actual user IDs
|
|
- **Edit prevention**: Don't allow editing messages that were slash commands (they didn't actually send)
|
|
|
|
---
|
|
|
|
## Task 6: Payment Flow UI
|
|
|
|
**Blocks:** Task 7 (Payment Card)
|
|
**Blocked by:** Tasks 4, 5
|
|
**Effort:** 3 days
|
|
|
|
**Acceptance criteria:**
|
|
- [ ] Confirmation screen shows recipient, amount, fee estimate
|
|
- [ ] Manual address entry field when recipient is @user
|
|
- [ ] "Confirm" triggers biometric authentication
|
|
- [ ] Success state shows tx hash
|
|
- [ ] Error states display appropriate messages
|
|
- [ ] "Cancel" returns to composer
|
|
- [ ] Loading state during tx submission
|
|
- [ ] All states match Element X design patterns
|
|
|
|
### Files
|
|
|
|
#### New: `features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentFlowState.kt`
|
|
```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<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`
|
|
```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<Plugin>,
|
|
private val presenter: PaymentFlowPresenter,
|
|
private val biometricAuthenticator: BiometricAuthenticator,
|
|
) : Node(buildContext, plugins = plugins) {
|
|
|
|
data class Inputs(
|
|
val roomId: String,
|
|
val amount: Double,
|
|
val unit: PaymentUnit,
|
|
val recipient: PaymentRecipient,
|
|
) : NodeInputs
|
|
|
|
private val inputs: Inputs = inputs()
|
|
|
|
init {
|
|
presenter.initialize(
|
|
roomId = inputs.roomId,
|
|
amount = inputs.amount,
|
|
unit = inputs.unit,
|
|
recipient = inputs.recipient,
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
override fun View(modifier: Modifier) {
|
|
val state = presenter.present()
|
|
|
|
PaymentFlowScreen(
|
|
state = state,
|
|
onNavigateBack = { navigateUp() },
|
|
onAuthenticateRequest = {
|
|
lifecycleScope.launch {
|
|
val activity = requireActivity() as FragmentActivity
|
|
val result = biometricAuthenticator.authenticate(
|
|
activity = activity,
|
|
title = "Confirm Payment",
|
|
subtitle = "Authenticate to send ${state.amount} ${state.unit.symbol}",
|
|
)
|
|
|
|
when (result) {
|
|
BiometricAuthenticator.AuthResult.Success -> {
|
|
state.eventActions.onAuthenticationResult(true, null)
|
|
}
|
|
is BiometricAuthenticator.AuthResult.Error -> {
|
|
state.eventActions.onAuthenticationResult(false, result.message)
|
|
}
|
|
BiometricAuthenticator.AuthResult.Cancelled -> {
|
|
state.eventActions.onAuthenticationResult(false, "cancelled")
|
|
}
|
|
}
|
|
}
|
|
},
|
|
modifier = modifier,
|
|
)
|
|
}
|
|
|
|
@AssistedFactory
|
|
interface Factory {
|
|
fun create(
|
|
buildContext: BuildContext,
|
|
plugins: List<Plugin>,
|
|
): PaymentFlowNode
|
|
}
|
|
}
|
|
```
|
|
|
|
### Key Implementation Details
|
|
|
|
1. **State machine**: Loading → EnterAddress (if needed) → Confirm → Authenticating → Submitting → Success/Error
|
|
2. **Biometric trigger**: Called from Node when entering `Authenticating` step
|
|
3. **Presenter pattern**: Follows Element X conventions with `@Composable present()` returning state
|
|
4. **Node pattern**: Uses Appyx for navigation, `@ContributesNode` for DI
|
|
|
|
### Gotchas
|
|
|
|
- **Activity context for biometrics**: Need `FragmentActivity` from the node, not Application context
|
|
- **Lifecycle scope**: Biometric callback must complete even if composition changes
|
|
- **Back handling**: Cancel must clean up state and navigate back
|
|
- **Fee estimation timing**: Estimate immediately on load; update if address changes
|
|
- **Keyboard handling**: Address input should show appropriate keyboard, dismiss on confirm
|
|
|
|
---
|
|
|
|
## Task 7: Payment Card Timeline Item
|
|
|
|
**Blocks:** Nothing
|
|
**Blocked by:** Tasks 1, 6, 8
|
|
**Effort:** 2.5 days
|
|
|
|
**Acceptance criteria:**
|
|
- [ ] `m.payment.cardano` event sends to room
|
|
- [ ] Payment card renders for sender
|
|
- [ ] Payment card renders for recipient
|
|
- [ ] Card shows amount, status, explorer link
|
|
- [ ] Tapping explorer link opens CardanoScan
|
|
- [ ] PENDING status updates to CONFIRMED (polling)
|
|
- [ ] Unknown/old clients show fallback text
|
|
|
|
### Files
|
|
|
|
#### New: `features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/timeline/PaymentEventContent.kt`
|
|
```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<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:
|
|
```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<String> {
|
|
val content = PaymentEventContent(
|
|
body = "Sent ${amountLovelace / 1_000_000.0} ADA",
|
|
txHash = txHash,
|
|
senderAddress = senderAddress,
|
|
recipientAddress = recipientAddress,
|
|
amountLovelace = amountLovelace.toString(),
|
|
status = "pending",
|
|
)
|
|
|
|
// Send as raw JSON event
|
|
// This uses the SDK's ability to send arbitrary message content
|
|
return room.sendMessage(
|
|
body = content.body,
|
|
htmlBody = null,
|
|
mentions = emptyList(),
|
|
).map { eventId ->
|
|
// TODO: Actually send as m.room.message with custom msgtype
|
|
// This requires SDK extension (Task 8)
|
|
eventId.value
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Key Implementation Details
|
|
|
|
1. **Event schema**: Custom `msgtype` of `m.payment.cardano` with standard `m.room.message` event type
|
|
2. **Fallback text**: `body` field contains human-readable text for clients without payment support
|
|
3. **Status tracking**: Initially "pending", updated to "confirmed" via polling
|
|
4. **Explorer URL**: CardanoScan for mainnet: `https://cardanoscan.io/transaction/{txHash}`
|
|
5. **Factory registration**: Payment factory must be called before falling back to UnknownContent
|
|
|
|
### Gotchas
|
|
|
|
- **Event editing**: Updating status requires event editing or relation events. For MVP, may just track locally.
|
|
- **Raw JSON access**: Need access to raw event JSON to parse custom content. SDK may not expose this cleanly.
|
|
- **Cross-client rendering**: Other Matrix clients will see fallback `body` text only.
|
|
- **Network switch**: `network` field should match (mainnet vs testnet). Don't mix.
|
|
- **Amount precision**: Store as string to avoid floating point issues with lovelace.
|
|
|
|
---
|
|
|
|
## Task 8: SDK Extension — Register `m.payment.cardano` Event Type
|
|
|
|
**Blocks:** Task 7 (fully functional)
|
|
**Blocked by:** Task 1
|
|
**Effort:** 2 days
|
|
|
|
**Acceptance criteria:**
|
|
- [ ] SDK recognizes `m.payment.cardano` msgtype
|
|
- [ ] Raw JSON content accessible from Kotlin
|
|
- [ ] Can send custom msgtype via SDK
|
|
- [ ] Event parsing doesn't crash on unknown content
|
|
- [ ] Existing message types unaffected
|
|
|
|
### Files
|
|
|
|
#### Analysis: Where Event Types Are Registered
|
|
|
|
In `matrix-rust-sdk` (the SDK Element X uses), event parsing happens in Rust. The Kotlin bindings expose:
|
|
|
|
```
|
|
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/
|
|
├── EventTimelineItemMapper.kt
|
|
├── TimelineEventContentMapper.kt
|
|
└── ...
|
|
```
|
|
|
|
The `TimelineEventContentMapper` converts Rust SDK types to Kotlin types.
|
|
|
|
For unknown message types, the SDK returns `OtherState` or falls back gracefully.
|
|
|
|
#### Approach: Client-Side Custom Parsing
|
|
|
|
Since modifying the Rust SDK is complex and we want Phase 1 to work without forking the SDK:
|
|
|
|
1. **Intercept at the Kotlin layer**: After SDK returns timeline items, check for messages with our custom msgtype
|
|
2. **Access raw content**: The SDK does provide raw JSON for unknown content
|
|
3. **Custom parsing**: Parse `m.payment.cardano` content ourselves
|
|
|
|
#### New: `features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/sdk/PaymentEventParser.kt`
|
|
```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<String>, // 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<String> {
|
|
val paymentJson = json.encodeToString(PaymentEventContent(
|
|
body = "Sent ${formatAda(amountLovelace)} ADA",
|
|
txHash = txHash,
|
|
senderAddress = senderAddress,
|
|
recipientAddress = recipientAddress,
|
|
amountLovelace = amountLovelace.toString(),
|
|
status = "pending",
|
|
))
|
|
|
|
// Send as text message with custom format marker
|
|
return room.sendMessage(
|
|
body = "Sent ${formatAda(amountLovelace)} ADA",
|
|
htmlBody = paymentJson, // Abuse formatted_body for JSON
|
|
format = "io.element.payment.cardano", // Custom format marker
|
|
mentions = emptyList(),
|
|
)
|
|
}
|
|
|
|
// TimelineItemPaymentFactory.kt - Updated parsing
|
|
fun create(content: MessageContent, ...): TimelineItemPaymentContent? {
|
|
// Check for our custom format
|
|
if (content.formattedBody != null &&
|
|
content.format == "io.element.payment.cardano") {
|
|
return try {
|
|
val paymentContent = json.decodeFromString<PaymentEventContent>(
|
|
content.formattedBody!!
|
|
)
|
|
// ... convert to TimelineItemPaymentContent
|
|
} catch (e: Exception) {
|
|
null
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
```
|
|
|
|
### Key Implementation Details
|
|
|
|
1. **MVP approach**: Use `formatted_body` to store JSON, with custom `format` marker
|
|
2. **Detection**: Check `format` field for our marker before parsing
|
|
3. **Fallback**: `body` contains human-readable text for other clients
|
|
4. **Future**: Proper SDK extension in Phase 2
|
|
|
|
### Gotchas
|
|
|
|
- **formatted_body parsing**: Other clients will see raw JSON if they render formatted_body. Consider encoding.
|
|
- **SDK changes**: If Element updates SDK, our parsing might break. Pin SDK version.
|
|
- **Event editing**: To update payment status, we need event editing support.
|
|
- **Redaction**: Users can delete payment events. Handle gracefully.
|
|
- **E2EE**: Payment events in encrypted rooms still work — content encrypted same as regular messages.
|
|
|
|
---
|
|
|
|
## Summary: Build Order
|
|
|
|
```
|
|
Week 1:
|
|
├── Task 1: Module Scaffolding (1 day) ─────────┐
|
|
├── Task 8: SDK Extension (2 days) ─────────────┤
|
|
└── Task 2: Key Storage (3 days) ───────────────┤
|
|
│
|
|
Week 2: │
|
|
├── Task 3: Blockfrost Client (1.5 days) ───────┤
|
|
├── Task 4: Transaction Builder (3 days) ←──────┘
|
|
└── Task 5: Slash Command (2 days)
|
|
|
|
Week 3:
|
|
├── Task 6: Payment Flow UI (3 days)
|
|
└── Task 7: Payment Card (2.5 days)
|
|
|
|
Buffer: 2 days for integration testing and bug fixes
|
|
```
|
|
|
|
**Total Estimate: 18 working days (~3.5 weeks)**
|
|
|
|
---
|
|
|
|
## Post-MVP: Phase 2 Teasers
|
|
|
|
1. **SSSS Integration**: Store encrypted wallet seed in Matrix account data
|
|
2. **Cross-device sync**: Restore wallet on new device after verification
|
|
3. **Recipient address lookup**: Query recipient's published Cardano address from their Matrix profile
|
|
4. **Balance widget**: Show wallet balance in room header
|
|
5. **Transaction history**: Query and display past payments
|
|
6. **Multi-asset support**: Handle native tokens
|
|
|
|
---
|
|
|
|
*Document prepared for Element X ADA project. Ready for implementation.*
|