# Element X Android — Cardano Wallet Integration Technical Review **Date:** 2026-03-26 **Author:** Kayos (Technical Review Subagent) **Target:** Integrating a built-in Cardano wallet with `/pay` slash command --- ## Executive Summary Element X Android is well-architected for extension. The codebase uses a clean feature module pattern with clear separation between API, implementation, and test modules. Key findings: 1. **Slash commands don't exist yet** — `/` is recognized but returns empty suggestions. This is a clean integration point. 2. **Android Keystore infrastructure exists** — `SecretKeyRepository` is already used for crypto operations; we can extend it. 3. **Timeline rendering is pluggable** — Adding a payment card bubble is straightforward via the factory pattern. 4. **Account data APIs are NOT exposed** — The Rust SDK has these but they're not surfaced to Kotlin yet. This is the main blocker. 5. **Widget infrastructure is solid** — Element Call uses WebView + postMessage; could inform a payment confirmation UI. **Honest Effort Estimate:** 3-4 weeks for a competent Android developer familiar with Matrix. --- ## 1. Slash Command System ### Current State The slash command infrastructure exists but is **intentionally empty**. Element X recognizes `/` as a command trigger but returns no suggestions. **Key Files:** ``` libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Suggestion.kt features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt ``` **Suggestion.kt** defines the types: ```kotlin sealed interface SuggestionType { data object Mention : SuggestionType // @ data object Command : SuggestionType // / data object Room : SuggestionType // # data object Emoji : SuggestionType // : data class Custom(val pattern: String) : SuggestionType } ``` **SuggestionsProcessor.kt** handles suggestions but explicitly returns empty for commands: ```kotlin SuggestionType.Command, SuggestionType.Emoji, is SuggestionType.Custom -> { // Clear suggestions emptyList() } ``` ### How to Add `/pay` 1. **Create a new slash command data model:** ```kotlin // features/wallet/api/src/main/kotlin/io/.../SlashCommand.kt sealed interface SlashCommand { data class Pay(val recipient: UserId?, val amount: String?) : SlashCommand // Future: /balance, /receive, etc. } ``` 2. **Extend SuggestionsProcessor to handle commands:** ```kotlin SuggestionType.Command -> { val commands = listOf( ResolvedSuggestion.Command("/pay", "Send ADA to someone"), // ... other commands ) commands.filter { it.command.contains(suggestion.text, ignoreCase = true) } } ``` 3. **Add `ResolvedSuggestion.Command` type:** ```kotlin // In libraries/textcomposer/impl/.../mentions/ResolvedSuggestion.kt data class Command(val command: String, val description: String) : ResolvedSuggestion ``` 4. **Intercept message sending in MessageComposerPresenter:** ```kotlin // In sendMessage(), before sending to timeline: val slashCommand = parseSlashCommand(message.markdown) if (slashCommand != null) { when (slashCommand) { is SlashCommand.Pay -> { // Navigate to payment flow instead of sending message navigator.navigateToPayment(slashCommand.recipient, slashCommand.amount) return@launch } } } ``` ### Files to Modify | File | Change | |------|--------| | `SuggestionsProcessor.kt` | Add command filtering logic | | `ResolvedSuggestion.kt` | Add `Command` type | | `MessageComposerPresenter.kt` | Intercept `/pay` before send | | New: `SlashCommandParser.kt` | Parse `/pay @user 100` syntax | --- ## 2. Key Storage (Android Keystore) ### Current Infrastructure Element X already uses Android Keystore for AES encryption. The infrastructure is **solid and reusable**. **Key Files:** ``` libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/SecretKeyRepository.kt libraries/cryptography/impl/src/main/kotlin/io/element/android/libraries/cryptography/impl/KeyStoreSecretKeyRepository.kt libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFile.kt ``` **SecretKeyRepository interface:** ```kotlin interface SecretKeyRepository { fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey fun deleteKey(alias: String) } ``` **KeyStoreSecretKeyRepository implementation:** ```kotlin @ContributesBinding(AppScope::class) class KeyStoreSecretKeyRepository( private val keyStore: KeyStore, ) : SecretKeyRepository { override fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey { // Uses Android Keystore with AES-GCM-128 val generator = KeyGenerator.getInstance(AESEncryptionSpecs.ALGORITHM, ANDROID_KEYSTORE) val keyGenSpec = KeyGenParameterSpec.Builder(alias, ...) .setUserAuthenticationRequired(requiresUserAuthentication) .build() // ... } } ``` ### What We Need to Add 1. **Cardano-specific key storage module:** ```kotlin // libraries/wallet/impl/src/.../CardanoKeyStorage.kt @Inject class CardanoKeyStorage( private val secretKeyRepository: SecretKeyRepository, private val encryptionService: EncryptionDecryptionService, private val sessionStore: SessionStore, ) { private val KEY_ALIAS_PREFIX = "cardano_wallet_" fun storeSpendingKey(sessionId: SessionId, encryptedKey: ByteArray): Result fun getSpendingKey(sessionId: SessionId): Result fun hasWallet(sessionId: SessionId): Boolean fun deleteWallet(sessionId: SessionId): Result } ``` 2. **Biometric/PIN protection option:** ```kotlin secretKeyRepository.getOrCreateKey( alias = "${KEY_ALIAS_PREFIX}${sessionId.value}", requiresUserAuthentication = true // Requires biometric/PIN ) ``` 3. **Leverage EncryptedFile pattern for key backup:** ```kotlin val encryptedFile = EncryptedFile(context, walletKeyFile) encryptedFile.openFileOutput().use { it.write(encryptedSpendingKey) } ``` ### Security Considerations - Keys should be **per-session** (each Matrix account has its own wallet) - Consider `setUserAuthenticationRequired(true)` for spending keys - Use existing `RandomSecretPassphraseProvider` pattern for derived keys - **DO NOT** store raw spending keys — always encrypt with Keystore-backed key --- ## 3. Widget Infrastructure (Element Call Pattern) ### How Element Call Works Element Call is embedded as a WebView-based widget with bidirectional message passing. **Key Files:** ``` features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt ``` **Architecture:** ``` ┌─────────────────────────────────────────┐ │ CallScreenView │ │ ┌───────────────────────────────────┐ │ │ │ WebView │ │ │ │ (Element Call HTML/JS) │ │ │ └───────────────────────────────────┘ │ │ ▲ │ │ │ │ postMessage │ │ ▼ ▼ │ │ ┌───────────────────────────────────┐ │ │ │ WebViewWidgetMessageInterceptor │ │ │ └───────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌───────────────────────────────────┐ │ │ │ MatrixWidgetDriver │ │ │ │ (SDK ↔ Widget communication) │ │ │ └───────────────────────────────────┘ │ └─────────────────────────────────────────┘ ``` **WidgetMessage structure:** ```kotlin @Serializable data class WidgetMessage( @SerialName("api") val direction: Direction, @SerialName("widgetId") val widgetId: String, @SerialName("requestId") val requestId: String, @SerialName("action") val action: Action, @SerialName("data") val data: JsonElement? = null, ) ``` ### Relevance for Wallet We could use this pattern for: 1. **Payment confirmation UI** — A WebView showing tx details before signing 2. **dApp browser** — Future widget-based dApp integration 3. **Cross-device signing** — If we need to coordinate with desktop wallets For MVP `/pay`, we probably **don't need widgets** — a native Compose UI is simpler. --- ## 4. Message Rendering (Payment Card Bubble) ### Current Architecture Timeline items are rendered through a factory + sealed class pattern. **Key Files:** ``` features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt ``` **TimelineItemEventContent** is a sealed interface with multiple implementations: ```kotlin sealed interface TimelineItemEventContent { val type: String } // Existing types: data class TimelineItemTextBasedContent(...) : TimelineItemEventContent data class TimelineItemImageContent(...) : TimelineItemEventContent data class TimelineItemPollContent(...) : TimelineItemEventContent data class TimelineItemLocationContent(...) : TimelineItemEventContent // ... etc ``` **TimelineItemContentFactory** maps SDK `EventContent` → UI content: ```kotlin suspend fun create(itemContent: EventContent, ...): TimelineItemEventContent { return when (itemContent) { is MessageContent -> messageFactory.create(...) is PollContent -> pollFactory.create(...) is UnknownContent -> TimelineItemUnknownContent // ... etc } } ``` **TimelineItemEventContentView** dispatches rendering: ```kotlin @Composable fun TimelineItemEventContentView(content: TimelineItemEventContent, ...) { when (content) { is TimelineItemTextBasedContent -> TimelineItemTextView(...) is TimelineItemPollContent -> TimelineItemPollView(...) // ... etc } } ``` ### Adding Payment Card 1. **Create content model:** ```kotlin // features/wallet/impl/src/.../timeline/TimelineItemPaymentContent.kt data class TimelineItemPaymentContent( val transactionId: String?, val senderAddress: String, val recipientAddress: String, val amount: String, // e.g., "100 ADA" val status: PaymentStatus, // Pending, Confirmed, Failed val timestamp: Long, val txHash: String?, // Once confirmed ) : TimelineItemEventContent { override val type: String = "m.payment" // Custom event type } enum class PaymentStatus { PENDING, CONFIRMED, FAILED } ``` 2. **Create factory:** ```kotlin // features/wallet/impl/src/.../timeline/TimelineItemContentPaymentFactory.kt @Inject class TimelineItemContentPaymentFactory { fun create(content: PaymentEventContent): TimelineItemPaymentContent { return TimelineItemPaymentContent( transactionId = content.transactionId, // ... map fields ) } } ``` 3. **Create view:** ```kotlin // features/wallet/impl/src/.../timeline/TimelineItemPaymentView.kt @Composable fun TimelineItemPaymentView( content: TimelineItemPaymentContent, modifier: Modifier = Modifier, ) { Card(modifier = modifier) { Column { // Icon + "Payment" header Row { Icon(Icons.Default.Payment) Text("Payment") } // Amount Text( text = content.amount, style = MaterialTheme.typography.headlineMedium ) // Status indicator StatusChip(content.status) // View on explorer link (if confirmed) if (content.txHash != null) { TextButton(onClick = { openExplorer(content.txHash) }) { Text("View on CardanoScan") } } } } } ``` 4. **Register in TimelineItemContentFactory:** ```kotlin // Add to existing factory is PaymentContent -> paymentFactory.create(content) ``` 5. **Register in TimelineItemEventContentView:** ```kotlin // Add to existing when block is TimelineItemPaymentContent -> TimelineItemPaymentView(content = content, modifier = modifier) ``` ### Custom Event Type We need a custom Matrix event type for payments. Options: 1. **`m.payment.cardano`** — Custom event type (requires SDK extension) 2. **`m.room.message` with `msgtype: "m.payment"`** — Easier but hacky 3. **Room state event** — For persistent payment records The SDK currently handles `UnknownContent` for unrecognized events, so we'd need to: - Either extend the Rust SDK to recognize `m.payment.*` - Or use the raw event JSON and parse it ourselves --- ## 5. Account Data APIs ### Current State: **BLOCKER** Account data (`m.account_data`) is the natural place to store: - Encrypted wallet seed (per-account) - Wallet addresses (for backup/sync) - Payment preferences **The Matrix Rust SDK has account data APIs, but Element X Android does NOT expose them.** The `MatrixClient` interface has no: - `getAccountData(type: String)` - `setAccountData(type: String, content: JsonObject)` - Room-level account data access ### Workarounds 1. **Local-only storage (MVP approach):** - Store wallet keys in Android Keystore - Store addresses in DataStore/preferences - **Con:** No cross-device sync 2. **Use a custom room as wallet state:** - Create a self-DM or special room - Store state as room events - **Con:** Awkward, discoverable 3. **Extend the SDK (proper solution):** - Add account data bindings to `RustMatrixClient` - Expose `setAccountData` / `getAccountData` - **Time:** 2-3 days for SDK extension ### Recommendation For MVP: **Local-only storage.** The user's wallet exists only on that device. For V2: Extend SDK to use account data for encrypted seed backup. --- ## 6. Recommended File Structure ``` features/ └── wallet/ ├── api/ │ └── src/main/kotlin/io/element/android/features/wallet/api/ │ ├── WalletEntryPoint.kt # Feature entry point │ ├── WalletState.kt # Wallet balance/state │ ├── PaymentRequest.kt # /pay parsing │ └── slash/ │ └── SlashCommand.kt # Slash command models │ ├── impl/ │ └── src/main/kotlin/io/element/android/features/wallet/impl/ │ ├── di/ │ │ └── WalletModule.kt # DI bindings │ ├── storage/ │ │ ├── CardanoKeyStorage.kt # Keystore wrapper │ │ └── WalletPreferences.kt # DataStore prefs │ ├── cardano/ │ │ ├── CardanoWalletManager.kt # Wallet operations │ │ ├── TransactionBuilder.kt # Tx construction │ │ └── BlockfrostClient.kt # Chain queries │ ├── payment/ │ │ ├── PaymentFlowNode.kt # Navigation node │ │ ├── PaymentPresenter.kt # MVP logic │ │ ├── PaymentState.kt # UI state │ │ └── PaymentView.kt # Compose UI │ ├── timeline/ │ │ ├── TimelineItemPaymentContent.kt │ │ ├── TimelineItemContentPaymentFactory.kt │ │ └── TimelineItemPaymentView.kt │ └── slash/ │ ├── SlashCommandParser.kt │ └── SlashCommandHandler.kt │ └── test/ └── src/main/kotlin/io/element/android/features/wallet/test/ ├── FakeCardanoWalletManager.kt └── FakeBlockfrostClient.kt ``` --- ## 7. Blockers & Gotchas ### Hard Blockers 1. **Account Data API not exposed** - Impact: No cross-device wallet sync - Mitigation: Local storage for MVP; SDK extension for V2 - Effort: 2-3 days to extend SDK 2. **Custom event types not recognized** - Impact: Payment events show as "Unknown event" - Mitigation: Extend SDK's event parsing or use raw JSON - Effort: 1-2 days ### Soft Blockers 3. **No existing Cardano library for Android/Kotlin** - We need: Key derivation (BIP39/CIP1852), transaction building - Options: cardano-serialization-lib (Rust via JNI), or port to Kotlin - Effort: 3-5 days for JNI wrapper OR 1-2 weeks for native Kotlin 4. **Blockfrost dependency** - Need API key management - Rate limiting concerns - Effort: 1 day ### Things That Are Easier Than Expected - **Keystore integration** — Existing infrastructure is excellent - **Timeline rendering** — Factory pattern makes it trivial - **Feature module structure** — Well-documented pattern - **Slash command hook** — Clean insertion point exists --- ## 8. Effort Estimate ### MVP: `/pay` Command End-to-End | Task | Days | Notes | |------|------|-------| | Slash command parser + suggestions | 2 | Extend existing infrastructure | | Payment flow UI (recipient, amount, confirm) | 3 | Compose screens | | Cardano key storage (Keystore) | 2 | Extend SecretKeyRepository | | Cardano transaction builder | 5 | Depends on Rust lib or Kotlin port | | Blockfrost integration | 1 | Submit tx, query balance | | Payment card timeline item | 2 | New content type + view | | Testing & polish | 3 | Unit tests, UI tests, edge cases | | **Total MVP** | **18 days** | ~3.5 weeks | ### V2: Cross-Device Sync | Task | Days | Notes | |------|------|-------| | Extend SDK for account data | 3 | Rust bindings + Kotlin wrapper | | Encrypted seed backup | 2 | Store in m.account_data | | Receive flow + QR | 2 | Address display, sharing | | **Total V2** | **7 days** | ~1.5 weeks | ### Full Feature Set | Task | Days | Notes | |------|------|-------| | MVP | 18 | As above | | V2 | 7 | Cross-device | | Balance widget | 2 | Room-level balance display | | Transaction history | 3 | Query + display | | Multi-asset support | 3 | Native tokens | | **Total Full** | **33 days** | ~6.5 weeks | --- ## 9. Recommendations 1. **Start with local-only MVP** — Don't block on SDK extensions 2. **Use cardano-serialization-lib via JNI** — Don't reinvent wheel 3. **Store wallet per-session** — Each Matrix account has its own wallet 4. **Use Blockfrost for MVP** — Move to own node later if needed 5. **Custom `m.payment.cardano` event** — Proper event type, extend SDK 6. **Fork Element X for initial dev** — Easier than patches --- ## 10. Files Quick Reference | Purpose | Location | |---------|----------| | Slash command suggestions | `features/messages/impl/.../suggestions/SuggestionsProcessor.kt` | | Message composer | `features/messages/impl/.../messagecomposer/MessageComposerPresenter.kt` | | Keystore | `libraries/cryptography/impl/.../KeyStoreSecretKeyRepository.kt` | | Timeline factory | `features/messages/impl/.../factories/event/TimelineItemContentFactory.kt` | | Timeline content view | `features/messages/impl/.../components/event/TimelineItemEventContentView.kt` | | Content models | `features/messages/impl/.../model/event/TimelineItem*.kt` | | Widget system | `features/call/impl/.../utils/DefaultCallWidgetProvider.kt` | | Encrypted DB | `libraries/encrypted-db/src/.../crypto/EncryptedFile.kt` | | Session storage | `libraries/session-storage/impl/.../DatabaseSessionStore.kt` | | Matrix client | `libraries/matrix/impl/.../RustMatrixClient.kt` | --- *End of Technical Review*