Fix ~60 compile errors - build now succeeds

- Fixed DI imports: javax.inject -> dev.zacsweers.metro
- Fixed cardano-client-lib API: KoiosBackendService constructor, Amount.quantity type
- Added kotlin-parcelize plugin
- Workaround for Timeline.sendRaw(): use message prefix approach
- Fixed MnemonicCode wordlist access
- Fixed Compose lifecycle/context handling
- Updated test fakes

BUILD SUCCESSFUL - unit tests still need updating for new APIs
This commit is contained in:
Kayos 2026-03-27 13:30:14 -07:00
parent b12b1e4770
commit bd883e9c3a
19 changed files with 279 additions and 706 deletions

View file

@ -1,185 +1,106 @@
# Phase 1 Status Report
**Date:** 2026-03-27
**Auditor:** Kayos (automated audit)
**Build Environment:** Docker on Lucy (mingc/android-build-box)
# Element X ADA Wallet - Phase 1 Status
## Executive Summary
## Current Build Status: ✅ COMPILES (with warnings)
**BUILD STATUS: ⚠️ PARTIAL**
- ✅ `features:wallet:api` - **COMPILES SUCCESSFULLY**
- ❌ `features:wallet:impl` - **FAILS (~60 errors)**
**Last build:** 2026-03-27
**Build command:** `./gradlew :features:wallet:impl:compileDebugKotlin`
**Result:** BUILD SUCCESSFUL in 7m 28s
The Phase 1 code has fundamental issues that prevent compilation. The code makes API assumptions that don't match the actual Element X and cardano-client-lib APIs. This requires significant rework before it can be tested on device.
## Issues Fixed
---
### 1. ✅ DI Import Errors (17 files)
- Changed from `javax.inject.Inject``dev.zacsweers.metro.Inject`
- Changed from `io.element.android.libraries.di.AppScope``dev.zacsweers.metro.AppScope`
- Fixed `@ContributesBinding`, `@SingleIn`, `@AssistedInject`, `@Assisted` imports
## Audit Findings
### 2. ✅ Parcelize Plugin
- Added `id("kotlin-parcelize")` to wallet impl build.gradle.kts
### Issues Fixed (Pushed to Gitea)
### 3. ✅ cardano-client-lib API Fixes
- Fixed `KoiosBackendService` constructor (use `new KoiosBackendService(baseUrl)` not `BackendFactory.getKoiosBackendService()`)
- Fixed `Amount.quantity` type - it's a `BigInteger`, not a `String`, so use `.toLong()` not `.toLongOrNull()`
- Fixed `Transaction.serializeToHex()` and `TransactionUtil.getTxHash()` usage
- Fixed `signedTx.body.fee.toLong()` usage
1. **DI Package Typo** ✅ FIXED
- `dev.zacsweeny.metro``dev.zacsweers.metro` (missing 's')
- Files: KoiosCardanoClient, DefaultTransactionBuilder, PaymentStatusPoller, WalletModule
### 4. ✅ Timeline.sendRaw() Issue
- **Solution:** The Matrix SDK doesn't expose raw event sending in the current version
- **Workaround:** Changed to send payment data as a structured message with `$CARDANO_PAY$` prefix
- The timeline UI will recognize this prefix and render a payment card
- This is a pragmatic Phase 1 solution; raw events can be added when SDK support arrives
2. **Missing Dependency** ✅ FIXED
- Added `implementation(projects.features.wallet.impl)` to messages:impl build.gradle.kts
### 5. ✅ MnemonicCode API
- Fixed `Words.ENGLISH.words` → use `MnemonicCode().wordList` directly
3. **Event Type Inconsistency** ✅ FIXED
- Standardized to `co.sulkta.payment.request` everywhere
- Updated TimelineItemPaymentContent.EVENT_TYPE and tests
### 6. ✅ PaymentConfirmationNode Lifecycle
- Changed `lifecycleScope.launch``rememberCoroutineScope().launch` (Compose-friendly)
- Changed `requireActivity()``LocalContext.current as? FragmentActivity`
4. **Scope Inconsistency** ✅ FIXED
- PaymentStatusPoller changed from AppScope to SessionScope (matches CardanoClient scope)
### 7. ✅ Button Icon API
- Changed `leadingIcon = { Icon(...) }``leadingIcon = IconSource.Vector(icon)`
5. **Sealed Interface Inheritance** ✅ FIXED
- TimelineItemPaymentContent can't inherit from sealed TimelineItemEventContent (different modules)
- Created TimelineItemPaymentContentWrapper adapter in messages:impl
## Remaining Warnings (non-blocking)
- Deprecated `Account(Network, String, Int)` constructor - cardano-client-lib deprecation
- Deprecated `Icons.Filled.Send` - use `Icons.AutoMirrored.Filled.Send` instead
- Single @Inject constructor suggestions
- Deprecated `setUserAuthenticationValidityDurationSeconds` - Android API deprecation
6. **cardano-client-lib API** ✅ PARTIALLY FIXED
- Changed `getNetworks()` to `getNetwork()` returning `Network` instead of `Networks`
## Test Status: ⚠️ Tests need updating
### Critical Issues Remaining (Blocks Compilation)
The unit tests need to be updated for the API changes:
- Test files reference old method signatures
- FakeCardanoKeyStorage and FakeWalletEntryPoint updated
- ~37 test errors to fix (API signature mismatches)
#### 1. DI Import Paths Wrong
**Impact:** ~20 compilation errors
**Files:** Most wallet/impl files
## Files Changed
```
features/wallet/impl/build.gradle.kts
features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/
├── DefaultWalletEntryPoint.kt
├── biometric/BiometricAuthenticator.kt
├── cardano/CardanoWalletManager.kt
├── cardano/DefaultTransactionBuilder.kt
├── cardano/KoiosCardanoClient.kt
├── cardano/PaymentStatusPoller.kt
├── di/WalletModule.kt
├── payment/DefaultPaymentEventSender.kt
├── payment/PaymentConfirmationNode.kt
├── payment/PaymentConfirmationView.kt
├── seedphrase/SeedPhraseManager.kt
├── slash/SlashCommandParser.kt
├── storage/CardanoKeyStorageImpl.kt
└── timeline/TimelineItemContentPaymentFactory.kt
The code uses:
```kotlin
import dev.zacsweers.metro.SessionScope // WRONG
import dev.zacsweers.metro.Inject // WRONG
features/wallet/test/build.gradle.kts
features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/
├── FakeWalletEntryPoint.kt
└── storage/FakeCardanoKeyStorage.kt
```
Should be:
```kotlin
import io.element.android.libraries.di.SessionScope
import javax.inject.Inject
```
## Next Steps
**Status:** Partially fixed, but more instances remain
1. **Fix unit tests** - Update test files to match new API signatures
2. **Integration testing** - Test actual Cardano transactions on Preview network
3. **Timeline rendering** - Implement payment card rendering in messages feature
4. **UI polish** - Add AutoMirrored icons, clean up deprecation warnings
#### 2. Timeline.sendRaw() Doesn't Exist
**Impact:** Critical - payment sending broken
**Files:** DefaultPaymentEventSender.kt
## Technical Notes
The code assumes:
```kotlin
timeline.sendRaw(eventType, content) // DOESN'T EXIST
```
The Matrix Rust SDK's Timeline API doesn't expose raw event sending. The BLOCKERS.md claimed this was resolved in SDK version 26.03.24, but grep shows no `sendRaw` method in the Element X libraries/matrix module.
**Required Fix:** Either:
- Find the actual API for sending custom events (if it exists)
- Use a workaround (message with structured body?)
- Wait for SDK to add this capability
#### 3. Koios Backend API Wrong
**Impact:** Balance/UTxO fetching broken
**File:** KoiosCardanoClient.kt
The code uses:
```kotlin
BackendFactory.getKoiosBackendService() // DOESN'T EXIST
```
cardano-client-lib doesn't have a `BackendFactory` class. The actual API is:
```kotlin
val backendService = KoiosBackendService(CardanoNetworkConfig.KOIOS_BASE_URL)
```
#### 4. Parcelize Plugin Missing
**Impact:** ~15 compilation errors
**Files:** ParsedPayCommand.kt, PaymentEntryNode.kt, PaymentConfirmationNode.kt, PaymentProgressNode.kt
The build.gradle.kts has `id("kotlin-parcelize")` but the imports use wrong paths:
```kotlin
import kotlinx.parcelize.Parcelize // Correct
import android.os.parcelize.Parcelize // Used incorrectly in some places
```
#### 5. Compose API Mismatches
**Impact:** UI won't compile
**File:** PaymentConfirmationView.kt
### Payment Event Sending Strategy
Since the Matrix Rust SDK doesn't expose `sendRaw()` for custom events, we use a message-based approach:
```kotlin
Button(
onClick = { ... },
icon = { Icon(...) } // WRONG: Button doesn't have icon parameter
)
// Payment messages have format:
"$CARDANO_PAY$" + json(PaymentEventData)
// Status updates have format:
"$CARDANO_STATUS$" + json(PaymentStatusUpdateData)
```
Element X's Button API differs from what was assumed.
The timeline UI should check for these prefixes and render payment cards accordingly.
#### 6. SeedPhraseManager ENGLISH Wordlist Reference
**Impact:** Mnemonic generation broken
**File:** SeedPhraseManager.kt
```kotlin
MnemonicCode.ENGLISH // DOESN'T EXIST
```
The correct API uses the MnemonicCode class differently.
---
## Architecture Assessment
### What's Correct
- Module structure (api/impl/test) follows Element X patterns
- Basic DI approach using Metro with @ContributesBinding
- Network configuration centralized in CardanoNetworkConfig
- Security design for key storage (Keystore, biometric, per-session)
- Timeline payment card concept
### What Needs Rework
1. **Payment Event Sending** - Fundamental approach needs re-evaluation since `sendRaw` doesn't exist
2. **Koios Client** - API usage completely wrong, needs rewrite to actual cardano-client-lib API
3. **Import Statements** - Systematic cleanup of DI and parcelize imports
4. **Compose Components** - Match Element X's actual component APIs
---
## Recommendations
### Immediate Actions Required
1. **Verify SDK Capabilities** - Check if Matrix Rust SDK actually supports custom event types at all. If not, Phase 1 needs fundamental redesign.
2. **Fix Koios Client** - Rewrite KoiosCardanoClient to use actual cardano-client-lib API:
```kotlin
val backendService = KoiosBackendService(CardanoNetworkConfig.KOIOS_BASE_URL)
val addressService = AddressService(backendService)
// etc.
```
3. **Element X API Review** - Before writing more code, do a thorough review of:
- Timeline event APIs
- Button/UI component APIs
- DI patterns used in existing features
### Phase 1 Assessment
**Ready for device testing?** ❌ NO
The code cannot compile. Estimated work to fix:
- Import cleanup: ~1 hour
- Koios client rewrite: ~2 hours
- Payment event sending redesign: ~4-8 hours (depends on SDK capabilities)
- UI component fixes: ~1 hour
- Testing: ~2 hours
**Total estimated fix time:** 10-14 hours of focused work
---
## Commits Made
1. `fix(wallet): resolve audit findings - DI typos, missing dependency, event type consistency`
2. `fix(wallet): resolve sealed interface inheritance issue`
3. `fix(wallet): fix cardano-client-lib API compatibility`
All changes pushed to `phase1-dev` branch on Gitea.
---
*Report generated automatically by Phase 1 audit subagent*
### cardano-client-lib Version
Using version 0.7.1 with Koios backend. Key classes:
- `KoiosBackendService(baseUrl)` - main backend
- `QuickTxBuilder(backendService)` - transaction building
- `Account(network, mnemonic)` - key derivation (deprecated but functional)
- `TransactionUtil.getTxHash(tx)` - hash calculation

View file

@ -8,6 +8,7 @@ import extension.setupDependencyInjection
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
alias(libs.plugins.kotlin.serialization)
}

View file

@ -9,13 +9,13 @@ package io.element.android.features.wallet.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.WalletEntryPoint
import io.element.android.features.wallet.impl.slash.ParsedPayCommand
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultWalletEntryPoint @Inject constructor() : WalletEntryPoint {
@ -46,10 +46,7 @@ class DefaultWalletEntryPoint @Inject constructor() : WalletEntryPoint {
}
override fun setAmount(amount: String?): Builder {
// Parse amount string to lovelace
// Assuming format like "10" (ADA) or "10000000" (lovelace if > 1M)
this.amountLovelace = amount?.toLongOrNull()?.let { value ->
// If it looks like ADA (small number), convert to lovelace
if (value < 1_000_000) {
value * 1_000_000
} else {
@ -59,12 +56,8 @@ class DefaultWalletEntryPoint @Inject constructor() : WalletEntryPoint {
return this
}
/**
* Sets the parsed slash command for pre-filling the payment flow.
*/
fun setParsedCommand(command: ParsedPayCommand?): Builder {
this.parsedCommand = command
// Also extract values from the command
when (command) {
is ParsedPayCommand.WithAddressRecipient -> {
this.amountLovelace = command.amount

View file

@ -11,20 +11,14 @@ import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import javax.inject.Inject
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
/**
* Helper class for biometric authentication.
*
* Supports:
* - Fingerprint
* - Face unlock
* - Device credential (PIN/pattern/password) as fallback
*/
@Inject
class BiometricAuthenticator {
class BiometricAuthenticator @Inject constructor() {
sealed interface AuthResult {
data object Success : AuthResult
@ -32,9 +26,6 @@ class BiometricAuthenticator {
data object Cancelled : AuthResult
}
/**
* Checks if biometric authentication is available on the device.
*/
fun canAuthenticate(context: Context): Boolean {
val biometricManager = BiometricManager.from(context)
return biometricManager.canAuthenticate(
@ -43,14 +34,6 @@ class BiometricAuthenticator {
) == BiometricManager.BIOMETRIC_SUCCESS
}
/**
* Shows biometric authentication prompt and suspends until result.
*
* @param activity The FragmentActivity to show the prompt on
* @param title The title shown in the prompt
* @param subtitle The subtitle shown in the prompt
* @return [AuthResult] indicating success, error, or cancellation
*/
suspend fun authenticate(
activity: FragmentActivity,
title: String = "Authenticate",
@ -81,8 +64,7 @@ class BiometricAuthenticator {
}
override fun onAuthenticationFailed() {
// Don't resume yet - user can retry
// This is called when the fingerprint doesn't match, etc.
// User can retry
}
}

View file

@ -7,10 +7,9 @@
package io.element.android.features.wallet.impl.cardano
import com.bloxbean.cardano.client.account.Account
import com.bloxbean.cardano.client.crypto.bip32.HdKeyPair
import io.element.android.libraries.di.AppScope
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import javax.inject.Inject
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.wallet.api.WalletState
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
@ -20,77 +19,16 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import timber.log.Timber
/**
* Manages the Cardano wallet for a Matrix session.
*
* ## Key Derivation
* Uses CIP-1852 (Cardano Shelley-era derivation):
* - Derivation path: `m/1852'/1815'/0'/{role}/{index}`
* - External address (receiving): `m/1852'/1815'/0'/0/0`
* - Staking key: `m/1852'/1815'/0'/2/0`
*
* ## Address Types
* - Base address: Payment key hash + Staking key hash (full delegation)
* - Stake address: For staking rewards (starts with `stake1` or `stake_test1`)
*
* All addresses are derived from the stored mnemonic using [CardanoKeyStorage].
*/
interface CardanoWalletManager {
/**
* Observable wallet state (balance, address, loading state).
*/
val walletState: StateFlow<WalletState>
/**
* Initializes the wallet manager for a session.
* Checks if a wallet exists and loads the address.
*/
suspend fun initialize(sessionId: SessionId)
/**
* Gets the base address for the wallet.
* Path: m/1852'/1815'/0'/0/{addressIndex}
*
* @param sessionId The Matrix session
* @return The Bech32-encoded base address (e.g., addr_test1q...)
*/
suspend fun getAddress(sessionId: SessionId): Result<String>
/**
* Gets the staking/reward address for the wallet.
* Path: m/1852'/1815'/0'/2/0
*
* @param sessionId The Matrix session
* @return The Bech32-encoded stake address (e.g., stake_test1...)
*/
suspend fun getStakeAddress(sessionId: SessionId): Result<String>
/**
* Gets the spending (signing) key for transaction signing.
* This is the private key for the external address.
*
* SENSITIVE: This method returns raw key material.
* Clear the ByteArray after use.
*
* @param sessionId The Matrix session
* @param addressIndex The address index (default 0)
*/
suspend fun getSpendingKey(sessionId: SessionId, addressIndex: Int = 0): Result<ByteArray>
/**
* Updates the cached balance by querying the chain.
*/
suspend fun refreshBalance(sessionId: SessionId)
/**
* Clears the cached wallet state.
*/
fun clearState()
}
/**
* Default implementation of [CardanoWalletManager].
*/
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultCardanoWalletManager @Inject constructor(
@ -112,7 +50,7 @@ class DefaultCardanoWalletManager @Inject constructor(
_walletState.value = WalletState(
hasWallet = true,
address = address,
balanceLovelace = null, // Will be populated by refreshBalance
balanceLovelace = null,
balanceAda = null,
isLoading = false,
error = null,
@ -152,17 +90,11 @@ class DefaultCardanoWalletManager @Inject constructor(
override suspend fun getSpendingKey(sessionId: SessionId, addressIndex: Int): Result<ByteArray> {
return runCatching {
// Retrieve mnemonic
val mnemonic = keyStorage.getMnemonic(sessionId).getOrThrow()
val mnemonicString = mnemonic.joinToString(" ")
// Create account and get private key bytes
val account = Account(CardanoNetworkConfig.getNetwork(), mnemonicString, addressIndex)
val privateKeyBytes = account.privateKeyBytes()
// Clear mnemonic string reference (best effort - JVM strings are immutable)
Timber.d("Retrieved spending key for session: ${sessionId.value}, index: $addressIndex")
privateKeyBytes
}
}
@ -173,11 +105,10 @@ class DefaultCardanoWalletManager @Inject constructor(
return
}
// Mark as loading while we fetch
_walletState.value = currentState.copy(isLoading = true, error = null)
try {
val result = cardanoClient.getBalance(currentState.address)
val result = cardanoClient.getBalance(currentState.address!!)
result.fold(
onSuccess = { lovelace ->
val adaString = formatLovelaceToAda(lovelace)
@ -206,10 +137,6 @@ class DefaultCardanoWalletManager @Inject constructor(
}
}
/**
* Formats lovelace amount to human-readable ADA string.
* 1 ADA = 1,000,000 lovelace
*/
private fun formatLovelaceToAda(lovelace: Long): String {
val ada = lovelace / 1_000_000.0
return String.format("%.6f", ada)

View file

@ -9,41 +9,27 @@ package io.element.android.features.wallet.impl.cardano
import com.bloxbean.cardano.client.account.Account
import com.bloxbean.cardano.client.api.model.Amount
import com.bloxbean.cardano.client.backend.api.BackendService
import com.bloxbean.cardano.client.backend.factory.BackendFactory
import com.bloxbean.cardano.client.backend.koios.KoiosBackendService
import com.bloxbean.cardano.client.function.helper.SignerProviders
import com.bloxbean.cardano.client.quicktx.QuickTxBuilder
import com.bloxbean.cardano.client.quicktx.Tx
import com.bloxbean.cardano.client.transaction.util.TransactionUtil
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.SessionScope
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.CardanoClient
import io.element.android.features.wallet.api.CardanoException
import io.element.android.features.wallet.api.PaymentRequest
import io.element.android.features.wallet.api.SignedTransaction
import io.element.android.features.wallet.api.TransactionBuilder
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.Arrays
import javax.inject.Inject
import java.math.BigInteger
/**
* Default implementation of [TransactionBuilder] using cardano-client-lib.
*
* ## UTXO Selection
* Uses largest-first coin selection strategy:
* 1. Sort UTXOs by amount descending
* 2. Select UTXOs until amount + fee is covered
* 3. Calculate change = total inputs - amount - fee
*
* ## Fee Calculation
* Fee is calculated using cardano-client-lib's QuickTxBuilder which
* uses protocol parameters to compute: fee = minFeeA * txSize + minFeeB
*
* ## Security
* - Signing keys are retrieved from storage (triggers biometric)
* - Key bytes are zeroed after use
* - Mnemonic is cleared from memory after key derivation
*/
@ContributesBinding(SessionScope::class)
class DefaultTransactionBuilder @Inject constructor(
@ -53,28 +39,22 @@ class DefaultTransactionBuilder @Inject constructor(
companion object {
private const val TAG = "TransactionBuilder"
/** Minimum ADA for a UTXO (Cardano protocol constraint) */
const val MIN_UTXO_LOVELACE = 1_000_000L // 1 ADA
/** Rough fee estimate for initial validation (actual fee calculated by library) */
const val MIN_UTXO_LOVELACE = 1_000_000L
private const val ROUGH_FEE_ESTIMATE = 200_000L
}
private val backendService: BackendService by lazy {
Timber.tag(TAG).d("Initializing Koios backend for tx building")
BackendFactory.getKoiosBackendService(CardanoNetworkConfig.KOIOS_BASE_URL)
KoiosBackendService(CardanoNetworkConfig.KOIOS_BASE_URL)
}
override suspend fun buildAndSign(request: PaymentRequest): Result<SignedTransaction> = withContext(Dispatchers.IO) {
Timber.tag(TAG).d("Building transaction: ${request.amountLovelace} lovelace to ${request.toAddress.take(20)}...")
runCatching {
// 1. Validate addresses
validateAddress(request.fromAddress, "sender")
validateAddress(request.toAddress, "recipient")
// 2. Validate amount (minimum 1 ADA)
if (request.amountLovelace < MIN_UTXO_LOVELACE) {
throw CardanoException.ApiException(
message = "Amount too small: minimum is 1 ADA (1,000,000 lovelace)",
@ -82,7 +62,6 @@ class DefaultTransactionBuilder @Inject constructor(
)
}
// 3. Fetch and validate UTXOs
val utxos = cardanoClient.getUtxos(request.fromAddress).getOrThrow()
if (utxos.isEmpty()) {
throw CardanoException.InsufficientFundsException(
@ -91,7 +70,6 @@ class DefaultTransactionBuilder @Inject constructor(
)
}
// 4. Calculate total available and do quick check
val totalAvailable = utxos.sumOf { it.amount }
val estimatedRequired = request.amountLovelace + ROUGH_FEE_ESTIMATE
@ -104,12 +82,10 @@ class DefaultTransactionBuilder @Inject constructor(
Timber.tag(TAG).d("UTXOs: ${utxos.size} totaling $totalAvailable lovelace")
// 5. Retrieve mnemonic (triggers biometric authentication via Android Keystore)
val mnemonicWords = keyStorage.getMnemonic(request.sessionId).getOrThrow()
val mnemonicString = mnemonicWords.joinToString(" ")
try {
// 6. Build and sign transaction
val signedTx = buildTransaction(
senderAddress = request.fromAddress,
recipientAddress = request.toAddress,
@ -120,100 +96,53 @@ class DefaultTransactionBuilder @Inject constructor(
Timber.tag(TAG).i("Transaction built: ${signedTx.txHash}, fee: ${signedTx.fee} lovelace")
signedTx
} finally {
// Best effort to clear mnemonic from memory
// Note: JVM String pooling makes this imperfect, but we try
Timber.tag(TAG).d("Transaction building complete")
}
}
}
/**
* Builds and signs a transaction using cardano-client-lib's QuickTx API.
*/
private fun buildTransaction(
senderAddress: String,
recipientAddress: String,
amountLovelace: Long,
mnemonic: String,
): SignedTransaction {
// Create Account from mnemonic (handles CIP-1852 derivation internally)
val account = Account(CardanoNetworkConfig.getNetwork(), mnemonic)
// Build transaction using QuickTx (high-level API)
val tx = Tx()
.payToAddress(recipientAddress, Amount.lovelace(amountLovelace))
.payToAddress(recipientAddress, Amount.lovelace(BigInteger.valueOf(amountLovelace)))
.from(senderAddress)
val quickTxBuilder = QuickTxBuilder(backendService)
// Build and sign
val result = quickTxBuilder
val signedTx = quickTxBuilder
.compose(tx)
.withSigner(SignerProviders.signerFrom(account))
.complete()
.buildAndSign()
if (!result.isSuccessful) {
val errorResponse = result.response ?: "Unknown error"
// Parse common error types
when {
errorResponse.contains("insufficient", ignoreCase = true) ||
errorResponse.contains("not enough", ignoreCase = true) -> {
throw CardanoException.InsufficientFundsException(
required = amountLovelace,
available = 0L // We don't know exact amount from error
)
}
errorResponse.contains("min", ignoreCase = true) &&
errorResponse.contains("utxo", ignoreCase = true) -> {
throw CardanoException.ApiException(
message = "Output too small: minimum UTXO value not met",
response = errorResponse
)
}
else -> {
throw CardanoException.ApiException(
message = "Transaction build failed: $errorResponse",
response = errorResponse
)
}
}
}
val signedTx = result.value
val txBytes = signedTx.serialize()
val txHash = signedTx.transactionId
val txHash = TransactionUtil.getTxHash(signedTx)
val txCbor = signedTx.serializeToHex()
val fee = signedTx.body.fee.toLong()
return SignedTransaction(
txCbor = txBytes.toHexString(),
txCbor = txCbor,
txHash = txHash,
fee = fee,
actualAmount = amountLovelace,
)
}
/**
* Validates a Cardano address format.
*/
private fun validateAddress(address: String, role: String) {
// Check prefix based on network
val expectedPrefix = CardanoNetworkConfig.ADDRESS_PREFIX
if (!address.startsWith(expectedPrefix)) {
throw CardanoException.InvalidAddressException(address)
}
// Basic length check (Cardano addresses are ~100+ chars)
if (address.length < 50) {
throw CardanoException.InvalidAddressException(address)
}
Timber.tag(TAG).d("$role address validated: ${address.take(20)}...")
}
/**
* Extension to convert ByteArray to hex string.
*/
private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }
}

View file

@ -7,34 +7,24 @@
package io.element.android.features.wallet.impl.cardano
import com.bloxbean.cardano.client.backend.api.BackendService
import com.bloxbean.cardano.client.backend.factory.BackendFactory
import com.bloxbean.cardano.client.backend.koios.KoiosBackendService
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.SessionScope
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.CardanoClient
import io.element.android.features.wallet.api.CardanoException
import io.element.android.features.wallet.api.ProtocolParameters
import io.element.android.features.wallet.api.TxStatus
import io.element.android.features.wallet.api.Utxo
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
/**
* Cardano blockchain client using the Koios public API.
*
* Koios is a decentralized API layer for Cardano that requires no API key.
* Rate limits: 100 requests per 10 seconds for anonymous users.
*
* Features:
* - Automatic retry with exponential backoff (3 attempts)
* - Rate limit handling with backoff
* - Network error recovery
*
* @see <a href="https://api.koios.rest/">Koios API Documentation</a>
*/
@ContributesBinding(SessionScope::class)
class KoiosCardanoClient @Inject constructor() : CardanoClient {
@ -43,17 +33,14 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
private const val MAX_RETRIES = 3
private const val INITIAL_BACKOFF_MS = 1000L
private const val MAX_BACKOFF_MS = 10000L
// Rate limiting: 100 req/10s = 1 req per 100ms minimum
private const val MIN_REQUEST_INTERVAL_MS = 100L
}
private val backendService: BackendService by lazy {
Timber.tag(TAG).d("Initializing Koios backend for ${CardanoNetworkConfig.NETWORK_NAME}")
BackendFactory.getKoiosBackendService(CardanoNetworkConfig.KOIOS_BASE_URL)
KoiosBackendService(CardanoNetworkConfig.KOIOS_BASE_URL)
}
// Simple rate limiting via mutex and timestamp tracking
private val rateLimitMutex = Mutex()
private var lastRequestTimeMs = 0L
@ -65,11 +52,10 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
val result = backendService.addressService.getAddressInfo(address)
if (result.isSuccessful) {
val info = result.value
// Find lovelace amount in the response
val lovelace = info.amount
?.find { it.unit == "lovelace" }
?.quantity
?.toLongOrNull()
?.toLong()
?: 0L
Result.success(lovelace)
} else {
@ -83,15 +69,13 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
withContext(Dispatchers.IO) {
throttleRequest()
// Fetch UTxOs with pagination (100 per page, page 1)
val result = backendService.utxoService.getUtxos(address, 100, 1)
if (result.isSuccessful) {
val utxos = result.value.map { utxo ->
// Extract lovelace amount from UTxO amounts
val lovelace = utxo.amount
?.find { it.unit == "lovelace" }
?.quantity
?.toLongOrNull()
?.toLong()
?: 0L
Utxo(
@ -113,7 +97,6 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
withContext(Dispatchers.IO) {
throttleRequest()
// Convert hex string to byte array
val txBytes = try {
signedTxCbor.hexToByteArray()
} catch (e: Exception) {
@ -148,14 +131,11 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
val result = backendService.transactionService.getTransaction(txHash)
if (result.isSuccessful) {
// If we got a response, the transaction is confirmed
Result.success(TxStatus.CONFIRMED)
} else {
// Check for 404 - transaction not found (pending or doesn't exist)
val response = result.response ?: ""
when {
response.contains("404") || response.contains("not found", ignoreCase = true) -> {
// Could be pending or never submitted
Result.success(TxStatus.PENDING)
}
else -> {
@ -179,7 +159,6 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
minFeeA = params.minFeeA?.toLong() ?: 44L,
minFeeB = params.minFeeB?.toLong() ?: 155381L,
maxTxSize = params.maxTxSize ?: 16384,
// coinsPerUtxoSize is the post-Babbage parameter (lovelace per byte)
utxoCostPerByte = params.coinsPerUtxoSize?.toLong() ?: 4310L,
)
)
@ -189,9 +168,6 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
}
}
/**
* Executes a request with retry logic and exponential backoff.
*/
private suspend fun <T> withRetry(
operation: String,
block: suspend () -> Result<T>,
@ -216,15 +192,12 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
val exception = result.exceptionOrNull() ?: Exception("Unknown error")
lastException = exception
// Check if error is retryable
val shouldRetry = when (exception) {
is CardanoException.RateLimitException -> {
// Use retry-after if provided, otherwise use backoff
backoffMs = exception.retryAfterMs ?: (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
true
}
is CardanoException.NetworkException -> {
// Retry on 5xx errors or network issues
exception.statusCode == null || exception.statusCode in 500..599
}
else -> false
@ -243,9 +216,6 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
return Result.failure(lastException ?: Exception("Max retries exceeded"))
}
/**
* Simple rate limiting - ensures minimum interval between requests.
*/
private suspend fun throttleRequest() {
rateLimitMutex.withLock {
val now = System.currentTimeMillis()
@ -257,9 +227,6 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
}
}
/**
* Parses error responses from Koios API into typed exceptions.
*/
private fun parseError(response: String?): CardanoException {
if (response == null) {
return CardanoException.NetworkException("No response from server")
@ -281,9 +248,6 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
}
}
/**
* Extension function to convert hex string to byte array.
*/
private fun String.hexToByteArray(): ByteArray {
require(length % 2 == 0) { "Hex string must have even length" }
return chunked(2)

View file

@ -16,7 +16,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import timber.log.Timber
import javax.inject.Inject
import dev.zacsweers.metro.Inject
/**
* Default implementation of [PaymentStatusPoller].

View file

@ -10,7 +10,7 @@ import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.AppScope
import dev.zacsweers.metro.AppScope
import kotlinx.serialization.json.Json
/**

View file

@ -7,27 +7,28 @@
package io.element.android.features.wallet.impl.payment
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.SessionScope
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.PaymentCardStatus
import io.element.android.features.wallet.api.PaymentEventSender
import io.element.android.features.wallet.api.PaymentRequest
import io.element.android.features.wallet.api.SignedTransaction
import io.element.android.features.wallet.api.timeline.TimelineItemPaymentContent
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject
/**
* Default implementation of [PaymentEventSender].
*
* Sends payment events as custom Matrix events using the raw event API.
* Since the Matrix SDK does not expose raw event sending, we send payment data
* as a structured message with a recognizable prefix that can be parsed by the UI.
*
* Event type: co.sulkta.payment.request
* Event content: JSON-serialized [PaymentEventData]
* Message format: $CARDANO_PAY${json}
* This allows the timeline UI to render a payment card instead of raw text.
*/
@ContributesBinding(SessionScope::class)
class DefaultPaymentEventSender @Inject constructor() : PaymentEventSender {
private val json = Json {
encodeDefaults = true
ignoreUnknownKeys = true
@ -48,12 +49,17 @@ class DefaultPaymentEventSender @Inject constructor() : PaymentEventSender {
network = network,
)
val content = json.encodeToString(paymentData)
val jsonContent = json.encodeToString(paymentData)
val message = "$PAYMENT_MESSAGE_PREFIX$jsonContent"
return timeline.sendRaw(
eventType = PAYMENT_EVENT_TYPE,
content = content,
)
// Send as a regular message - the timeline renderer will recognize the prefix
return runCatching {
timeline.sendMessage(
body = message,
htmlBody = null,
intentionalMentions = emptyList(),
)
}
}
override suspend fun sendStatusUpdate(
@ -68,25 +74,26 @@ class DefaultPaymentEventSender @Inject constructor() : PaymentEventSender {
network = network,
)
val content = json.encodeToString(statusData)
val jsonContent = json.encodeToString(statusData)
val message = "$STATUS_MESSAGE_PREFIX$jsonContent"
return timeline.sendRaw(
eventType = STATUS_UPDATE_EVENT_TYPE,
content = content,
)
return runCatching {
timeline.sendMessage(
body = message,
htmlBody = null,
intentionalMentions = emptyList(),
)
}
}
companion object {
/** Custom event type for Cardano payment requests (reverse-domain format) */
const val PAYMENT_EVENT_TYPE = "co.sulkta.payment.request"
/** Custom event type for payment status updates */
const val STATUS_UPDATE_EVENT_TYPE = "co.sulkta.payment.status"
/** Prefix for payment messages - UI parses this to render payment cards */
const val PAYMENT_MESSAGE_PREFIX = "\$CARDANO_PAY$"
/** Prefix for status update messages */
const val STATUS_MESSAGE_PREFIX = "\$CARDANO_STATUS$"
}
}
/**
* JSON-serializable payment event data.
*/
@kotlinx.serialization.Serializable
data class PaymentEventData(
val amountLovelace: Long,
@ -97,9 +104,6 @@ data class PaymentEventData(
val network: String,
)
/**
* JSON-serializable payment status update data.
*/
@kotlinx.serialization.Serializable
data class PaymentStatusUpdateData(
val txHash: String,

View file

@ -8,7 +8,9 @@ package io.element.android.features.wallet.impl.payment
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentActivity
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -23,14 +25,8 @@ import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
/**
* Node for the payment confirmation screen.
*
* Handles biometric authentication before proceeding to payment submission.
*/
@ContributesNode(SessionScope::class)
@AssistedInject
class PaymentConfirmationNode(
class PaymentConfirmationNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenterFactory: PaymentConfirmationPresenter.Factory,
@ -61,15 +57,15 @@ class PaymentConfirmationNode(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
PaymentConfirmationView(
state = state,
onConfirm = {
// Trigger biometric authentication
lifecycleScope.launch {
val activity = requireActivity() as? FragmentActivity
coroutineScope.launch {
val activity = context as? FragmentActivity
if (activity == null) {
// Fallback: proceed without biometric (should not happen)
callback.onConfirmed()
return@launch
}
@ -85,11 +81,10 @@ class PaymentConfirmationNode(
callback.onConfirmed()
}
is BiometricAuthenticator.AuthResult.Error -> {
// Authentication failed - stay on screen
// Could show a snackbar here
// Stay on screen
}
BiometricAuthenticator.AuthResult.Cancelled -> {
// User cancelled - stay on screen
// Stay on screen
}
}
}

View file

@ -46,6 +46,7 @@ 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.IconSource
/**
* Payment confirmation screen.
@ -98,7 +99,7 @@ fun PaymentConfirmationView(
onClick = { state.eventSink(PaymentFlowEvents.ConfirmPayment); onConfirm() },
enabled = !state.isFeeLoading && !state.insufficientFunds,
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
leadingIcon = { Icon(Icons.Default.Send, contentDescription = null) },
leadingIcon = IconSource.Vector(Icons.Default.Send),
)
}
}

View file

@ -7,104 +7,27 @@
package io.element.android.features.wallet.impl.seedphrase
import com.bloxbean.cardano.client.crypto.bip39.MnemonicCode
import com.bloxbean.cardano.client.crypto.bip39.Words
import io.element.android.libraries.di.AppScope
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import javax.inject.Inject
import dev.zacsweers.metro.Inject
import timber.log.Timber
import java.security.SecureRandom
/**
* Result of seed phrase validation.
*/
sealed class SeedPhraseValidationResult {
data class Valid(val wordCount: Int) : SeedPhraseValidationResult()
data class Invalid(val error: String) : SeedPhraseValidationResult()
}
/**
* Manages BIP-39 seed phrase generation, validation, and display.
*
* ## Security Requirements for UI
* When displaying seed phrases in the UI:
* - Apply `FLAG_SECURE` to prevent screenshots: `window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)`
* - Clear the word list from memory when the screen is dismissed
* - Never log seed phrases
*
* ## Supported Word Counts
* - 12 words (128-bit entropy) - Standard for many wallets
* - 15 words (160-bit entropy)
* - 18 words (192-bit entropy)
* - 21 words (224-bit entropy)
* - 24 words (256-bit entropy) - Maximum security, used by default
*/
interface SeedPhraseManager {
/**
* Generates a new 24-word BIP-39 mnemonic.
*
* @return A list of 24 words from the BIP-39 English wordlist
*/
fun generateSeedPhrase(): List<String>
/**
* Generates a seed phrase with a specific word count.
*
* @param wordCount Must be 12, 15, 18, 21, or 24
* @return A list of words from the BIP-39 English wordlist
* @throws IllegalArgumentException if wordCount is invalid
*/
fun generateSeedPhrase(wordCount: Int): List<String>
/**
* Validates a seed phrase.
*
* Checks:
* 1. Word count (12, 15, 18, 21, or 24)
* 2. All words are in the BIP-39 English wordlist
* 3. Checksum is valid
*
* @param words The seed phrase as a list of words
* @return Validation result
*/
fun validate(words: List<String>): SeedPhraseValidationResult
/**
* Validates a seed phrase from a space-separated string.
*
* @param seedPhrase The seed phrase as a space-separated string
* @return Validation result
*/
fun validate(seedPhrase: String): SeedPhraseValidationResult
/**
* Normalizes a seed phrase input.
* - Trims whitespace
* - Lowercases all words
* - Removes extra spaces
*
* @param input Raw user input
* @return Normalized word list
*/
fun normalize(input: String): List<String>
/**
* Gets the BIP-39 English wordlist for autocomplete.
*/
fun getWordlist(): List<String>
/**
* Suggests words from the wordlist that start with the given prefix.
*
* @param prefix The prefix to match
* @param limit Maximum number of suggestions
* @return List of matching words
*/
fun suggestWords(prefix: String, limit: Int = 5): List<String>
}
/**
* Default implementation using cardano-client-lib.
*/
@ContributesBinding(AppScope::class)
class DefaultSeedPhraseManager @Inject constructor() : SeedPhraseManager {
@ -123,7 +46,7 @@ class DefaultSeedPhraseManager @Inject constructor() : SeedPhraseManager {
private val mnemonicCode = MnemonicCode()
private val wordList: List<String> by lazy {
Words.ENGLISH.words.toList()
mnemonicCode.wordList
}
override fun generateSeedPhrase(): List<String> {
@ -145,7 +68,6 @@ class DefaultSeedPhraseManager @Inject constructor() : SeedPhraseManager {
val words = try {
mnemonicCode.toMnemonic(entropy)
} finally {
// Clear entropy immediately
entropy.fill(0)
}
@ -154,14 +76,12 @@ class DefaultSeedPhraseManager @Inject constructor() : SeedPhraseManager {
}
override fun validate(words: List<String>): SeedPhraseValidationResult {
// Check word count
if (words.size !in VALID_WORD_COUNTS) {
return SeedPhraseValidationResult.Invalid(
"Invalid word count: ${words.size}. Expected one of: $VALID_WORD_COUNTS"
)
}
// Check all words are in wordlist
val invalidWords = words.filter { it.lowercase() !in wordList }
if (invalidWords.isNotEmpty()) {
return SeedPhraseValidationResult.Invalid(
@ -169,7 +89,6 @@ class DefaultSeedPhraseManager @Inject constructor() : SeedPhraseManager {
)
}
// Validate checksum
return try {
mnemonicCode.check(words.map { it.lowercase() })
SeedPhraseValidationResult.Valid(words.size)

View file

@ -7,7 +7,7 @@
package io.element.android.features.wallet.impl.slash
import io.element.android.libraries.matrix.api.core.UserId
import javax.inject.Inject
import dev.zacsweers.metro.Inject
import java.math.BigDecimal
/**

View file

@ -14,8 +14,9 @@ import android.security.keystore.KeyProperties
import android.util.Base64
import com.bloxbean.cardano.client.account.Account
import com.bloxbean.cardano.client.crypto.bip39.MnemonicCode
import io.element.android.libraries.di.AppScope
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
import io.element.android.features.wallet.api.storage.WalletCreationResult
import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
@ -30,24 +31,7 @@ import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
/**
* Implementation of [CardanoKeyStorage] using Android Keystore for secure key management.
*
* ## Security Design
* - Mnemonic is encrypted with AES-GCM using an Android Keystore-backed key
* - Keystore key requires biometric/PIN authentication for every operation
* - Keys are invalidated if biometric enrollment changes
* - Per-session isolation via unique key aliases
*
* ## Storage Layout
* - SharedPreferences: `cardano_wallet_storage`
* - `encrypted_mnemonic_{sessionId}`: Base64-encoded encrypted mnemonic
* - `iv_{sessionId}`: Base64-encoded initialization vector
* - Android Keystore:
* - Alias: `cardano_wallet_{sessionId}`
*/
@ContributesBinding(AppScope::class)
class CardanoKeyStorageImpl @Inject constructor(
@ApplicationContext private val context: Context,
@ -61,10 +45,8 @@ class CardanoKeyStorageImpl @Inject constructor(
private const val KEYSTORE_ALIAS_PREFIX = "cardano_wallet_"
private const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_TAG_LENGTH = 128
private const val GCM_IV_LENGTH = 12
private const val AES_KEY_SIZE = 256
private const val MNEMONIC_WORD_COUNT = 24
private const val MNEMONIC_ENTROPY_BYTES = 32 // 256 bits for 24 words
private const val MNEMONIC_ENTROPY_BYTES = 32
}
private val keyStore: KeyStore by lazy {
@ -89,21 +71,16 @@ class CardanoKeyStorageImpl @Inject constructor(
throw IllegalStateException("Wallet already exists for session: ${sessionId.value}")
}
// Generate 256-bit entropy for 24-word mnemonic
val entropy = ByteArray(MNEMONIC_ENTROPY_BYTES)
SecureRandom().nextBytes(entropy)
// Generate mnemonic using cardano-client-lib
val mnemonicCode = MnemonicCode()
val wordList = mnemonicCode.toMnemonic(entropy)
// Clear entropy after use
entropy.fill(0)
// Store encrypted mnemonic
storeMnemonic(sessionId, wordList)
// Derive addresses
val mnemonicString = wordList.joinToString(" ")
val account = Account(CardanoNetworkConfig.getNetwork(), mnemonicString)
@ -125,12 +102,10 @@ class CardanoKeyStorageImpl @Inject constructor(
throw IllegalStateException("Wallet already exists for session: ${sessionId.value}")
}
// Validate mnemonic length
require(mnemonic.size in listOf(12, 15, 18, 21, 24)) {
"Invalid mnemonic length: ${mnemonic.size} words. Expected 12, 15, 18, 21, or 24."
}
// Validate mnemonic checksum
val mnemonicCode = MnemonicCode()
try {
mnemonicCode.check(mnemonic)
@ -138,7 +113,6 @@ class CardanoKeyStorageImpl @Inject constructor(
throw IllegalArgumentException("Invalid mnemonic: ${e.message}")
}
// Verify it produces valid Cardano addresses
val mnemonicString = mnemonic.joinToString(" ")
val account = try {
Account(CardanoNetworkConfig.getNetwork(), mnemonicString)
@ -146,7 +120,6 @@ class CardanoKeyStorageImpl @Inject constructor(
throw IllegalArgumentException("Failed to derive Cardano keys: ${e.message}")
}
// Store encrypted mnemonic
storeMnemonic(sessionId, mnemonic)
Timber.i("Imported Cardano wallet for session: ${sessionId.value}")
@ -186,13 +159,11 @@ class CardanoKeyStorageImpl @Inject constructor(
runCatching {
val sanitizedId = sanitizeSessionId(sessionId)
// Delete from SharedPreferences
prefs.edit()
.remove(KEY_ENCRYPTED_MNEMONIC_PREFIX + sanitizedId)
.remove(KEY_IV_PREFIX + sanitizedId)
.apply()
// Delete Keystore key
val alias = KEYSTORE_ALIAS_PREFIX + sanitizedId
if (keyStore.containsAlias(alias)) {
keyStore.deleteEntry(alias)
@ -202,19 +173,14 @@ class CardanoKeyStorageImpl @Inject constructor(
}
}
/**
* Creates or retrieves an AES key from Android Keystore with strict security requirements.
*/
private fun getOrCreateSecretKey(sessionId: SessionId): SecretKey {
val alias = KEYSTORE_ALIAS_PREFIX + sanitizeSessionId(sessionId)
// Check if key exists
val existingKey = keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry
if (existingKey != null) {
return existingKey.secretKey
}
// Generate new key with strict security parameters
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
val keySpec = KeyGenParameterSpec.Builder(
alias,
@ -223,11 +189,8 @@ class CardanoKeyStorageImpl @Inject constructor(
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(AES_KEY_SIZE)
// Require user authentication for every crypto operation
.setUserAuthenticationRequired(true)
// Auth required every time (no grace period)
.setUserAuthenticationValidityDurationSeconds(-1)
// CRITICAL: Invalidate key if biometric enrollment changes
.setInvalidatedByBiometricEnrollment(true)
.build()
@ -235,36 +198,24 @@ class CardanoKeyStorageImpl @Inject constructor(
return keyGenerator.generateKey()
}
/**
* Encrypts and stores the mnemonic.
*/
private fun storeMnemonic(sessionId: SessionId, mnemonic: List<String>) {
val sanitizedId = sanitizeSessionId(sessionId)
val secretKey = getOrCreateSecretKey(sessionId)
// Encrypt mnemonic
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val mnemonicBytes = mnemonic.joinToString(" ").toByteArray(Charsets.UTF_8)
val encryptedBytes = cipher.doFinal(mnemonicBytes)
// Clear plaintext immediately
mnemonicBytes.fill(0)
// Store encrypted data and IV
prefs.edit()
.putString(KEY_ENCRYPTED_MNEMONIC_PREFIX + sanitizedId, Base64.encodeToString(encryptedBytes, Base64.NO_WRAP))
.putString(KEY_IV_PREFIX + sanitizedId, Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
.apply()
}
/**
* Retrieves and decrypts the mnemonic.
*
* @throws KeyPermanentlyInvalidatedException if biometrics changed
* @throws IllegalStateException if no wallet exists
*/
private fun retrieveMnemonic(sessionId: SessionId): List<String> {
val sanitizedId = sanitizeSessionId(sessionId)
@ -280,12 +231,10 @@ class CardanoKeyStorageImpl @Inject constructor(
val secretKey = try {
getOrCreateSecretKey(sessionId)
} catch (e: KeyPermanentlyInvalidatedException) {
// Biometric enrollment changed - wallet is invalidated
Timber.e(e, "Key invalidated due to biometric change for session: ${sessionId.value}")
throw e
}
// Decrypt
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
@ -293,16 +242,11 @@ class CardanoKeyStorageImpl @Inject constructor(
val decryptedBytes = cipher.doFinal(encryptedBytes)
val mnemonicString = String(decryptedBytes, Charsets.UTF_8)
// Clear decrypted bytes
decryptedBytes.fill(0)
return mnemonicString.split(" ")
}
/**
* Sanitizes session ID for use in file/key names.
* Removes special characters that could cause issues.
*/
private fun sanitizeSessionId(sessionId: SessionId): String {
return sessionId.value
.replace("@", "")

View file

@ -6,7 +6,7 @@
package io.element.android.features.wallet.impl.timeline
import javax.inject.Inject
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.PaymentCardStatus
import io.element.android.features.wallet.api.timeline.TimelineItemPaymentContent
import io.element.android.features.wallet.impl.payment.DefaultPaymentEventSender
@ -15,14 +15,13 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.longOrNull
import timber.log.Timber
/**
* Factory for creating [TimelineItemPaymentContent] from raw payment events.
* Factory for creating [TimelineItemPaymentContent] from message content.
*
* Parses custom events with type "co.sulkta.payment.request" and extracts the payment data.
* Parses messages with the $CARDANO_PAY$ prefix and extracts payment data.
*/
@Inject
class TimelineItemContentPaymentFactory {
@ -32,32 +31,64 @@ class TimelineItemContentPaymentFactory {
}
/**
* Check if an event type is a payment event.
* Check if a message is a payment message.
*/
fun isPaymentEventType(eventType: String): Boolean {
return eventType == DefaultPaymentEventSender.PAYMENT_EVENT_TYPE
fun isPaymentMessage(body: String): Boolean {
return body.startsWith(DefaultPaymentEventSender.PAYMENT_MESSAGE_PREFIX)
}
/**
* Check if an event type is a payment status update.
* Check if a message is a status update message.
*/
fun isStatusUpdateEventType(eventType: String): Boolean {
return eventType == DefaultPaymentEventSender.STATUS_UPDATE_EVENT_TYPE
fun isStatusUpdateMessage(body: String): Boolean {
return body.startsWith(DefaultPaymentEventSender.STATUS_MESSAGE_PREFIX)
}
/**
* Create a [TimelineItemPaymentContent] from raw JSON event content.
* Create a [TimelineItemPaymentContent] from a message body.
*
* @param rawJson The raw JSON content from the Matrix event
* @param isSentByMe Whether the current user sent this event
* @param body The message body
* @param isSentByMe Whether the current user sent this message
* @return The parsed payment content, or null if parsing failed
*/
fun createFromMessage(body: String, isSentByMe: Boolean): TimelineItemPaymentContent? {
return try {
val jsonContent = when {
body.startsWith(DefaultPaymentEventSender.PAYMENT_MESSAGE_PREFIX) -> {
body.removePrefix(DefaultPaymentEventSender.PAYMENT_MESSAGE_PREFIX)
}
body.startsWith(DefaultPaymentEventSender.STATUS_MESSAGE_PREFIX) -> {
// Status updates don't create full payment content
return null
}
else -> return null
}
val data = json.decodeFromString<PaymentEventData>(jsonContent)
TimelineItemPaymentContent(
amountLovelace = data.amountLovelace,
toAddress = data.toAddress,
fromAddress = data.fromAddress,
txHash = data.txHash,
status = parseStatus(data.status),
network = data.network,
isSentByMe = isSentByMe,
fallbackText = "💰 ${TimelineItemPaymentContent.formatAda(data.amountLovelace)}",
)
} catch (e: Exception) {
Timber.w(e, "Failed to parse payment message")
null
}
}
/**
* Create a [TimelineItemPaymentContent] from raw JSON event content (legacy support).
*/
fun createFromRaw(rawJson: String, isSentByMe: Boolean): TimelineItemPaymentContent? {
return try {
// Try to parse the content field from the raw event JSON
val eventJson = json.parseToJsonElement(rawJson).jsonObject
val content = eventJson["content"]?.jsonObject ?: eventJson
val data = parsePaymentData(content)
if (data != null) {
TimelineItemPaymentContent(
@ -103,21 +134,21 @@ class TimelineItemContentPaymentFactory {
val amountLovelace = content["amount_lovelace"]?.jsonPrimitive?.longOrNull
?: content["amountLovelace"]?.jsonPrimitive?.longOrNull
?: return null
val toAddress = content["to_address"]?.jsonPrimitive?.content
?: content["toAddress"]?.jsonPrimitive?.content
?: return null
val fromAddress = content["from_address"]?.jsonPrimitive?.content
?: content["fromAddress"]?.jsonPrimitive?.content
?: return null
val txHash = content["tx_hash"]?.jsonPrimitive?.content
?: content["txHash"]?.jsonPrimitive?.content
val status = content["status"]?.jsonPrimitive?.content ?: "pending"
val network = content["network"]?.jsonPrimitive?.content ?: "mainnet"
PaymentEventData(
amountLovelace = amountLovelace,
toAddress = toAddress,
@ -140,11 +171,4 @@ class TimelineItemContentPaymentFactory {
else -> PaymentCardStatus.PENDING
}
}
companion object {
/** Custom event type for Cardano payment requests */
const val PAYMENT_EVENT_TYPE = DefaultPaymentEventSender.PAYMENT_EVENT_TYPE
/** Custom event type for payment status updates */
const val STATUS_UPDATE_EVENT_TYPE = DefaultPaymentEventSender.STATUS_UPDATE_EVENT_TYPE
}
}

View file

@ -14,6 +14,8 @@ android {
dependencies {
api(projects.features.wallet.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.architecture)
implementation(projects.tests.testutils)
implementation(libs.coroutines.core)
}

View file

@ -11,22 +11,22 @@ import com.bumble.appyx.core.node.Node
import io.element.android.features.wallet.api.WalletEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.lambda.lambdaError
class FakeWalletEntryPoint : WalletEntryPoint {
class Builder : WalletEntryPoint.Builder {
override fun setRoomId(roomId: RoomId): Builder = this
override fun setRecipientUserId(userId: UserId?): Builder = this
override fun setRecipientAddress(address: String?): Builder = this
override fun setAmount(amount: String?): Builder = this
override fun build(): Node = lambdaError()
override fun setRoomId(roomId: RoomId): WalletEntryPoint.Builder = this
override fun setRecipientUserId(userId: UserId?): WalletEntryPoint.Builder = this
override fun setRecipientAddress(address: String?): WalletEntryPoint.Builder = this
override fun setAmount(amount: String?): WalletEntryPoint.Builder = this
override fun build(): Node {
throw NotImplementedError("FakeWalletEntryPoint cannot build a real node")
}
}
override fun paymentFlowBuilder(
parentNode: Node,
buildContext: BuildContext,
callback: WalletEntryPoint.Callback,
): WalletEntryPoint.Builder {
return Builder()
}
): WalletEntryPoint.Builder = Builder()
}

View file

@ -12,132 +12,99 @@ import io.element.android.libraries.matrix.api.core.SessionId
/**
* Fake implementation of [CardanoKeyStorage] for testing.
*
* Stores wallets in memory without encryption. NOT for production use.
*/
class FakeCardanoKeyStorage : CardanoKeyStorage {
private val wallets = mutableMapOf<String, WalletData>()
private val wallets = mutableMapOf<String, FakeWallet>()
var generateWalletError: Throwable? = null
var importWalletError: Throwable? = null
var getMnemonicError: Throwable? = null
var getAddressError: Throwable? = null
/**
* Test data for generated wallets.
*/
var testMnemonic: List<String> = listOf(
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "abandon", "art"
var generateWalletResult: Result<WalletCreationResult> = Result.success(
WalletCreationResult(
mnemonic = List(24) { "word$it" },
baseAddress = "addr_test1qpfake",
stakeAddress = "stake_test1upfake",
)
)
var importWalletResult: Result<String> = Result.success("addr_test1qpimported")
var getMnemonicResult: Result<List<String>>? = null
var getBaseAddressResult: Result<String>? = null
var getStakeAddressResult: Result<String>? = null
var deleteWalletResult: Result<Unit> = Result.success(Unit)
private data class WalletData(
val mnemonic: List<String>,
val baseAddress: String,
val stakeAddress: String,
)
var testBaseAddress: String = "addr_test1qp2fg770ddmqxxduasjsas8rgimrhknmqjn43mj74g7ta2tjt0n5nh4t5xqf6lp5mwfpksj9csjg9s4kgfhvwj7m7dcq9qf7zj"
var testStakeAddress: String = "stake_test1upehh7l0vv6ep8vr4n30pjdv6t2vpexs2h7xtpk8erzk06s25g8y3"
override suspend fun hasWallet(sessionId: SessionId): Boolean {
return wallets.containsKey(sessionId.value)
}
override suspend fun generateWallet(sessionId: SessionId): Result<WalletCreationResult> {
generateWalletError?.let { return Result.failure(it) }
if (wallets.containsKey(sessionId.value)) {
return Result.failure(IllegalStateException("Wallet already exists for session"))
}
val wallet = FakeWallet(
mnemonic = testMnemonic,
baseAddress = testBaseAddress,
stakeAddress = testStakeAddress,
)
wallets[sessionId.value] = wallet
return Result.success(
WalletCreationResult(
mnemonic = testMnemonic,
baseAddress = testBaseAddress,
stakeAddress = testStakeAddress,
return generateWalletResult.onSuccess { result ->
if (wallets.containsKey(sessionId.value)) {
return Result.failure(IllegalStateException("Wallet already exists"))
}
wallets[sessionId.value] = WalletData(
mnemonic = result.mnemonic,
baseAddress = result.baseAddress,
stakeAddress = result.stakeAddress,
)
)
}
}
override suspend fun importWallet(sessionId: SessionId, mnemonic: List<String>): Result<String> {
importWalletError?.let { return Result.failure(it) }
if (wallets.containsKey(sessionId.value)) {
return Result.failure(IllegalStateException("Wallet already exists for session"))
return importWalletResult.onSuccess { address ->
if (wallets.containsKey(sessionId.value)) {
return Result.failure(IllegalStateException("Wallet already exists"))
}
wallets[sessionId.value] = WalletData(
mnemonic = mnemonic,
baseAddress = address,
stakeAddress = "stake_test1upimported",
)
}
val wallet = FakeWallet(
mnemonic = mnemonic,
baseAddress = testBaseAddress,
stakeAddress = testStakeAddress,
)
wallets[sessionId.value] = wallet
return Result.success(testBaseAddress)
}
override suspend fun getMnemonic(sessionId: SessionId): Result<List<String>> {
getMnemonicError?.let { return Result.failure(it) }
getMnemonicResult?.let { return it }
val wallet = wallets[sessionId.value]
?: return Result.failure(IllegalStateException("No wallet found for session"))
?: return Result.failure(IllegalStateException("No wallet"))
return Result.success(wallet.mnemonic)
}
override suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int): Result<String> {
getAddressError?.let { return Result.failure(it) }
getBaseAddressResult?.let { return it }
val wallet = wallets[sessionId.value]
?: return Result.failure(IllegalStateException("No wallet found for session"))
// For testing, just append the index to the address if non-zero
val address = if (addressIndex == 0) {
wallet.baseAddress
} else {
"${wallet.baseAddress}_$addressIndex"
}
return Result.success(address)
?: return Result.failure(IllegalStateException("No wallet"))
return Result.success(wallet.baseAddress)
}
override suspend fun getStakeAddress(sessionId: SessionId): Result<String> {
getAddressError?.let { return Result.failure(it) }
getStakeAddressResult?.let { return it }
val wallet = wallets[sessionId.value]
?: return Result.failure(IllegalStateException("No wallet found for session"))
?: return Result.failure(IllegalStateException("No wallet"))
return Result.success(wallet.stakeAddress)
}
override suspend fun deleteWallet(sessionId: SessionId): Result<Unit> {
wallets.remove(sessionId.value)
return Result.success(Unit)
return deleteWalletResult
}
/**
* Clears all stored wallets. Use in test teardown.
*/
fun clear() {
fun reset() {
wallets.clear()
generateWalletError = null
importWalletError = null
getMnemonicError = null
getAddressError = null
generateWalletResult = Result.success(
WalletCreationResult(
mnemonic = List(24) { "word$it" },
baseAddress = "addr_test1qpfake",
stakeAddress = "stake_test1upfake",
)
)
importWalletResult = Result.success("addr_test1qpimported")
getMnemonicResult = null
getBaseAddressResult = null
getStakeAddressResult = null
deleteWalletResult = Result.success(Unit)
}
/**
* Returns the number of stored wallets.
*/
fun walletCount(): Int = wallets.size
private data class FakeWallet(
val mnemonic: List<String>,
val baseAddress: String,
val stakeAddress: String,
)
}