21 KiB
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:
- Slash commands don't exist yet —
/is recognized but returns empty suggestions. This is a clean integration point. - Android Keystore infrastructure exists —
SecretKeyRepositoryis already used for crypto operations; we can extend it. - Timeline rendering is pluggable — Adding a payment card bubble is straightforward via the factory pattern.
- Account data APIs are NOT exposed — The Rust SDK has these but they're not surfaced to Kotlin yet. This is the main blocker.
- 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:
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:
SuggestionType.Command,
SuggestionType.Emoji,
is SuggestionType.Custom -> {
// Clear suggestions
emptyList()
}
How to Add /pay
-
Create a new slash command data model:
// 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. } -
Extend SuggestionsProcessor to handle commands:
SuggestionType.Command -> { val commands = listOf( ResolvedSuggestion.Command("/pay", "Send ADA to someone"), // ... other commands ) commands.filter { it.command.contains(suggestion.text, ignoreCase = true) } } -
Add
ResolvedSuggestion.Commandtype:// In libraries/textcomposer/impl/.../mentions/ResolvedSuggestion.kt data class Command(val command: String, val description: String) : ResolvedSuggestion -
Intercept message sending in MessageComposerPresenter:
// 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:
interface SecretKeyRepository {
fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey
fun deleteKey(alias: String)
}
KeyStoreSecretKeyRepository implementation:
@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
-
Cardano-specific key storage module:
// 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<Unit> fun getSpendingKey(sessionId: SessionId): Result<ByteArray> fun hasWallet(sessionId: SessionId): Boolean fun deleteWallet(sessionId: SessionId): Result<Unit> } -
Biometric/PIN protection option:
secretKeyRepository.getOrCreateKey( alias = "${KEY_ALIAS_PREFIX}${sessionId.value}", requiresUserAuthentication = true // Requires biometric/PIN ) -
Leverage EncryptedFile pattern for key backup:
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
RandomSecretPassphraseProviderpattern 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:
@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:
- Payment confirmation UI — A WebView showing tx details before signing
- dApp browser — Future widget-based dApp integration
- 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:
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:
suspend fun create(itemContent: EventContent, ...): TimelineItemEventContent {
return when (itemContent) {
is MessageContent -> messageFactory.create(...)
is PollContent -> pollFactory.create(...)
is UnknownContent -> TimelineItemUnknownContent
// ... etc
}
}
TimelineItemEventContentView dispatches rendering:
@Composable
fun TimelineItemEventContentView(content: TimelineItemEventContent, ...) {
when (content) {
is TimelineItemTextBasedContent -> TimelineItemTextView(...)
is TimelineItemPollContent -> TimelineItemPollView(...)
// ... etc
}
}
Adding Payment Card
-
Create content model:
// 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 } -
Create factory:
// features/wallet/impl/src/.../timeline/TimelineItemContentPaymentFactory.kt @Inject class TimelineItemContentPaymentFactory { fun create(content: PaymentEventContent): TimelineItemPaymentContent { return TimelineItemPaymentContent( transactionId = content.transactionId, // ... map fields ) } } -
Create view:
// 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") } } } } } -
Register in TimelineItemContentFactory:
// Add to existing factory is PaymentContent -> paymentFactory.create(content) -
Register in TimelineItemEventContentView:
// 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:
m.payment.cardano— Custom event type (requires SDK extension)m.room.messagewithmsgtype: "m.payment"— Easier but hacky- 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
-
Local-only storage (MVP approach):
- Store wallet keys in Android Keystore
- Store addresses in DataStore/preferences
- Con: No cross-device sync
-
Use a custom room as wallet state:
- Create a self-DM or special room
- Store state as room events
- Con: Awkward, discoverable
-
Extend the SDK (proper solution):
- Add account data bindings to
RustMatrixClient - Expose
setAccountData/getAccountData - Time: 2-3 days for SDK extension
- Add account data bindings to
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
-
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
-
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
-
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
-
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
- Start with local-only MVP — Don't block on SDK extensions
- Use cardano-serialization-lib via JNI — Don't reinvent wheel
- Store wallet per-session — Each Matrix account has its own wallet
- Use Blockfrost for MVP — Move to own node later if needed
- Custom
m.payment.cardanoevent — Proper event type, extend SDK - 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