Add Element X Android technical review for Cardano wallet integration

This commit is contained in:
Kayos 2026-03-26 19:56:59 -07:00
parent 10e73d484b
commit 046a254e17

581
ELEMENT-X-ANDROID-REVIEW.md Normal file
View file

@ -0,0 +1,581 @@
# 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<Unit>
fun getSpendingKey(sessionId: SessionId): Result<ByteArray>
fun hasWallet(sessionId: SessionId): Boolean
fun deleteWallet(sessionId: SessionId): Result<Unit>
}
```
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*