diff --git a/PHASE1-PLAN.md b/PHASE1-PLAN.md index e184a16ee1..e10971b01a 100644 --- a/PHASE1-PLAN.md +++ b/PHASE1-PLAN.md @@ -1,6 +1,6 @@ # Element X ADA Wallet — Phase 1 Implementation Plan -**Date:** 2026-03-27 +**Date:** 2026-03-27 (Updated) **Author:** Kayos **Target:** Local-only MVP — `/pay 10 ADA @jacob` end-to-end **Repo:** `Sulkta-Coop/element-x-ada` @@ -13,16 +13,16 @@ Phase 1 delivers a functional Cardano lite wallet embedded in Element X Android: - User types `/pay 10 ADA @jacob` in a DM - Confirmation screen opens with amount + recipient - Biometric authentication triggers -- Transaction is signed and submitted via Blockfrost +- Transaction is signed and submitted via Koios - Payment card renders in timeline for both parties - Recipient taps to view tx on CardanoScan **Constraints:** - Local-only (no SSSS sync — Phase 2) - Keys stored in Android Keystore -- Blockfrost for chain queries +- Koios for chain queries (no API key needed) - `cardano-client-lib` (pure Java, no JNI) -- Custom Matrix event: `m.payment.cardano` +- Custom Matrix event: `m.payment.cardano` (sent via raw event API) --- @@ -37,8 +37,8 @@ Task 1: Module Scaffolding │ ├──────────────────┬──────────────────┐ ▼ ▼ ▼ -Task 2: Key Storage Task 3: Blockfrost Task 8: SDK Extension - │ │ │ +Task 2: Key Storage Task 3: Koios Task 8: SDK Extension + │ Client │ └────────┬─────────┘ │ ▼ │ Task 4: Transaction Builder │ @@ -114,9 +114,9 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.uiStrings) - // Cardano + // Cardano - using Koios backend (no API key required) implementation("com.bloxbean.cardano:cardano-client-lib:0.7.1") - implementation("com.bloxbean.cardano:cardano-client-backend-blockfrost:0.7.1") + implementation("com.bloxbean.cardano:cardano-client-backend-koios:0.7.1") implementation("com.bloxbean.cardano:cardano-client-crypto:0.7.1") // Biometric @@ -199,13 +199,12 @@ import dagger.Module import dagger.Provides import io.element.android.features.wallet.api.WalletEntryPoint import io.element.android.features.wallet.impl.DefaultWalletEntryPoint -import io.element.android.features.wallet.impl.cardano.BlockfrostClient -import io.element.android.features.wallet.impl.cardano.BlockfrostClientImpl +import io.element.android.features.wallet.impl.cardano.CardanoClient +import io.element.android.features.wallet.impl.cardano.KoiosCardanoClient import io.element.android.features.wallet.impl.storage.CardanoKeyStorage import io.element.android.features.wallet.impl.storage.CardanoKeyStorageImpl import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SingleIn -import javax.inject.Named @Module @ContributesTo(AppScope::class) @@ -217,16 +216,7 @@ interface WalletModule { fun bindCardanoKeyStorage(impl: CardanoKeyStorageImpl): CardanoKeyStorage @Binds - fun bindBlockfrostClient(impl: BlockfrostClientImpl): BlockfrostClient - - companion object { - @Provides - @Named("blockfrost_project_id") - fun provideBlockfrostProjectId(): String { - // TODO: Move to BuildConfig or encrypted storage - return BuildConfig.BLOCKFROST_PROJECT_ID - } - } + fun bindCardanoClient(impl: KoiosCardanoClient): CardanoClient } ``` @@ -244,17 +234,13 @@ Add dependency: implementation(projects.features.wallet.impl) ``` -#### New: `gradle.properties` addition -```properties -BLOCKFROST_PROJECT_ID=mainnetXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -``` - ### Key Implementation Details 1. **Module structure follows Element X pattern**: `api/impl/test` separation 2. **Anvil for DI**: Use `@ContributesBinding` and `@ContributesTo` annotations 3. **AppScope**: Wallet services are app-scoped (singleton per app lifecycle) 4. **SessionScope consideration**: If wallet-per-account is needed later, migrate to SessionScope +5. **No API key needed**: Koios public API at `api.koios.rest` requires no registration ### Gotchas @@ -321,7 +307,6 @@ import androidx.security.crypto.MasterKey import com.bloxbean.cardano.client.account.Account import com.bloxbean.cardano.client.common.model.Networks import com.bloxbean.cardano.client.crypto.bip39.MnemonicCode -import com.bloxbean.cardano.client.crypto.bip39.Words import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.cryptography.api.SecretKeyRepository import io.element.android.libraries.di.AppScope @@ -524,11 +509,8 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Text @@ -730,30 +712,28 @@ class BiometricAuthenticator @Inject constructor() { --- -## Task 3: Blockfrost Client +## Task 3: Koios Client **Blocks:** Task 4 (Transaction Builder) **Blocked by:** Task 1 -**Effort:** 1.5 days +**Effort:** 1 day **Acceptance criteria:** - [ ] Fetch UTXOs for an address returns correct data - [ ] Fetch balance matches sum of UTXO values - [ ] Submit transaction returns tx hash - [ ] Query tx status returns confirmation count -- [ ] Rate limiting handled gracefully (429 → exponential backoff) - [ ] Network errors surface as typed errors, not crashes -- [ ] API key securely stored and injected ### Files -#### New: `features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/BlockfrostClient.kt` +#### New: `features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/CardanoClient.kt` ```kotlin package io.element.android.features.wallet.impl.cardano import com.bloxbean.cardano.client.api.model.Utxo -interface BlockfrostClient { +interface CardanoClient { suspend fun getUtxos(address: String): Result> suspend fun getBalance(address: String): Result // lovelace suspend fun submitTransaction(txCbor: ByteArray): Result // tx hash @@ -779,33 +759,40 @@ data class ProtocolParameters( ) ``` -#### New: `features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/BlockfrostClientImpl.kt` +#### New: `features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt` ```kotlin package io.element.android.features.wallet.impl.cardano import com.bloxbean.cardano.client.api.model.Utxo -import com.bloxbean.cardano.client.backend.blockfrost.service.BFBackendService +import com.bloxbean.cardano.client.backend.factory.BackendFactory +import com.bloxbean.cardano.client.common.model.Networks +import com.bloxbean.cardano.client.util.Constants import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import javax.inject.Inject -import javax.inject.Named +/** + * Cardano chain client using Koios public API. + * + * Koios is a decentralized API layer for Cardano with no API key required. + * Rate limits: 100 req/10s for anonymous users. + * + * @see Koios API Documentation + */ @ContributesBinding(AppScope::class) -class BlockfrostClientImpl @Inject constructor( - @Named("blockfrost_project_id") private val projectId: String, -) : BlockfrostClient { +class KoiosCardanoClient @Inject constructor() : CardanoClient { companion object { - private const val MAINNET_URL = "https://cardano-mainnet.blockfrost.io/api/v0" private const val MAX_RETRIES = 3 private const val INITIAL_BACKOFF_MS = 1000L } + // Koios backend - no API key needed private val backendService by lazy { - BFBackendService(MAINNET_URL, projectId) + BackendFactory.getKoiosBackendService(Constants.KOIOS_MAINNET_URL) } override suspend fun getUtxos(address: String): Result> = @@ -815,7 +802,7 @@ class BlockfrostClientImpl @Inject constructor( if (result.isSuccessful) { Result.success(result.value) } else { - Result.failure(BlockfrostException(result.response)) + Result.failure(CardanoClientException(result.response)) } } } @@ -831,7 +818,7 @@ class BlockfrostClientImpl @Inject constructor( ?.quantity?.toLongOrNull() ?: 0L Result.success(lovelace) } else { - Result.failure(BlockfrostException(result.response)) + Result.failure(CardanoClientException(result.response)) } } } @@ -843,7 +830,7 @@ class BlockfrostClientImpl @Inject constructor( if (result.isSuccessful) { Result.success(result.value) } else { - Result.failure(BlockfrostException(result.response)) + Result.failure(CardanoClientException(result.response)) } } } @@ -857,7 +844,7 @@ class BlockfrostClientImpl @Inject constructor( Result.success(TransactionStatus( txHash = txHash, confirmed = true, - confirmations = 1, // Blockfrost doesn't give confirmation count directly + confirmations = 1, slot = tx.slot, blockHeight = tx.blockHeight, )) @@ -871,7 +858,7 @@ class BlockfrostClientImpl @Inject constructor( blockHeight = null, )) } else { - Result.failure(BlockfrostException(result.response)) + Result.failure(CardanoClientException(result.response)) } } } @@ -891,7 +878,7 @@ class BlockfrostClientImpl @Inject constructor( keyDeposit = params.keyDeposit.toLong(), )) } else { - Result.failure(BlockfrostException(result.response)) + Result.failure(CardanoClientException(result.response)) } } } @@ -910,11 +897,11 @@ class BlockfrostClientImpl @Inject constructor( lastException = exception // Check if retryable - if (exception is BlockfrostException) { - if (exception.isRateLimited()) { + if (exception is CardanoClientException) { + if (exception.isRateLimited() || exception.isRetryable()) { delay(backoff) backoff *= 2 - } else if (!exception.isRetryable()) { + } else { return result } } else { @@ -926,7 +913,7 @@ class BlockfrostClientImpl @Inject constructor( } } -class BlockfrostException(val response: String?) : Exception(response) { +class CardanoClientException(val response: String?) : Exception(response) { fun isRateLimited(): Boolean = response?.contains("429") == true fun isRetryable(): Boolean = response?.let { it.contains("429") || it.contains("500") || it.contains("503") @@ -934,59 +921,20 @@ class BlockfrostException(val response: String?) : Exception(response) { } ``` -#### New: `features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/BlockfrostConfig.kt` -```kotlin -package io.element.android.features.wallet.impl.cardano - -import android.content.Context -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import javax.inject.Inject - -class BlockfrostConfig @Inject constructor( - private val context: Context, -) { - companion object { - private const val PREFS_NAME = "blockfrost_config" - private const val KEY_PROJECT_ID = "project_id" - } - - private val masterKey by lazy { - MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - } - - private fun getPrefs() = EncryptedSharedPreferences.create( - context, - PREFS_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - - fun getProjectId(): String? = getPrefs().getString(KEY_PROJECT_ID, null) - - fun setProjectId(projectId: String) { - getPrefs().edit().putString(KEY_PROJECT_ID, projectId).apply() - } -} -``` - ### Key Implementation Details -1. **cardano-client-lib backend**: Use `BFBackendService` which wraps Blockfrost REST API -2. **Retry strategy**: Exponential backoff for 429 (rate limit) and 5xx errors -3. **API key storage**: Encrypted SharedPreferences (not EncryptedSharedPreferences if biometric not needed for API key) -4. **Thread context**: All network calls on `Dispatchers.IO` +1. **Koios backend**: Use `BackendFactory.getKoiosBackendService(Constants.KOIOS_MAINNET_URL)` from cardano-client-lib +2. **No API key**: Koios public API at `api.koios.rest` requires no registration +3. **Same interface**: Koios backend implements the same `BackendService` interface as Blockfrost +4. **Retry strategy**: Exponential backoff for 429 (rate limit) and 5xx errors +5. **Thread context**: All network calls on `Dispatchers.IO` ### Gotchas -- **Rate limits**: Blockfrost free tier is 10 req/sec, 500 burst. Implement backoff. -- **Mainnet vs testnet**: Need to switch URL for testing. Consider environment flag. -- **UTXO pagination**: Blockfrost paginates at 100. For large wallets, implement pagination. -- **API key in BuildConfig**: For development; production should use remote config or encrypted storage. -- **TLS certificate pinning**: Consider adding for production security. +- **Rate limits**: Koios public tier is 100 req/10s. Implement backoff for rate limit responses. +- **Mainnet vs testnet**: Use `Constants.KOIOS_TESTNET_URL` for testing. +- **UTXO pagination**: Koios paginates at 100. For large wallets, implement pagination. +- **Decentralized**: Koios has multiple providers; some may have slightly different sync status. --- @@ -1052,36 +1000,30 @@ package io.element.android.features.wallet.impl.cardano import com.bloxbean.cardano.client.account.Account import com.bloxbean.cardano.client.address.AddressProvider import com.bloxbean.cardano.client.api.model.Amount -import com.bloxbean.cardano.client.backend.blockfrost.service.BFBackendService +import com.bloxbean.cardano.client.backend.factory.BackendFactory import com.bloxbean.cardano.client.coinselection.impl.LargestFirstUtxoSelectionStrategy import com.bloxbean.cardano.client.common.model.Networks -import com.bloxbean.cardano.client.function.TxBuilder -import com.bloxbean.cardano.client.function.TxBuilderContext -import com.bloxbean.cardano.client.function.helper.BalanceTxBuilders -import com.bloxbean.cardano.client.function.helper.InputBuilders import com.bloxbean.cardano.client.function.helper.SignerProviders import com.bloxbean.cardano.client.quicktx.QuickTxBuilder import com.bloxbean.cardano.client.quicktx.Tx +import com.bloxbean.cardano.client.util.Constants import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject -import javax.inject.Named @ContributesBinding(AppScope::class) class TransactionBuilderImpl @Inject constructor( - @Named("blockfrost_project_id") private val projectId: String, - private val blockfrostClient: BlockfrostClient, + private val cardanoClient: CardanoClient, ) : TransactionBuilder { companion object { - private const val MAINNET_URL = "https://cardano-mainnet.blockfrost.io/api/v0" private const val MIN_UTXO_LOVELACE = 1_000_000L // 1 ADA minimum for outputs } private val backendService by lazy { - BFBackendService(MAINNET_URL, projectId) + BackendFactory.getKoiosBackendService(Constants.KOIOS_MAINNET_URL) } override suspend fun buildPayment( @@ -1101,7 +1043,7 @@ class TransactionBuilderImpl @Inject constructor( } // Check UTXOs exist - val utxosResult = blockfrostClient.getUtxos(senderAddress) + val utxosResult = cardanoClient.getUtxos(senderAddress) val utxos = utxosResult.getOrThrow() if (utxos.isEmpty()) { throw TransactionBuildError.NoUtxosAvailable @@ -1180,7 +1122,7 @@ class TransactionBuilderImpl @Inject constructor( ): Result = withContext(Dispatchers.IO) { runCatching { // Get protocol parameters - val params = blockfrostClient.getProtocolParameters().getOrThrow() + val params = cardanoClient.getProtocolParameters().getOrThrow() // Estimate tx size (typical simple payment is ~250-350 bytes) val estimatedSize = 350 @@ -1220,7 +1162,7 @@ import javax.inject.Inject class CardanoWalletManager @Inject constructor( private val keyStorage: CardanoKeyStorage, - private val blockfrostClient: BlockfrostClient, + private val cardanoClient: CardanoClient, private val transactionBuilder: TransactionBuilder, ) { private val _walletState = MutableStateFlow(WalletState( @@ -1257,7 +1199,7 @@ class CardanoWalletManager @Inject constructor( } suspend fun refreshBalance(address: String) { - val result = blockfrostClient.getBalance(address) + val result = cardanoClient.getBalance(address) result.onSuccess { lovelace -> _walletState.value = _walletState.value.copy( balanceLovelace = lovelace, @@ -1295,7 +1237,7 @@ class CardanoWalletManager @Inject constructor( } // Submit transaction - val txHash = blockfrostClient.submitTransaction(builtTx.txCbor).getOrElse { + val txHash = cardanoClient.submitTransaction(builtTx.txCbor).getOrElse { return Result.failure(it) } @@ -1421,6 +1363,7 @@ class SlashCommandParser @Inject constructor() { return ParseResult.NotACommand } + val match = PAY_REGEX.matchEntire(trimmed) ?: return ParseResult.Error("Invalid format. Use: /pay <@user or addr1...>") @@ -1574,7 +1517,6 @@ when (val parseResult = slashCommandParser.parse(message.markdown)) { package io.element.android.features.wallet.impl.slash import io.element.android.features.wallet.api.slash.PaymentRecipient -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.MatrixRoom import javax.inject.Inject @@ -1685,7 +1627,7 @@ data class PaymentFlowState( val senderBalanceAda: String?, val txHash: String?, val error: PaymentError?, - val eventActions: PaymentFlowEvents, + val eventSink: (PaymentFlowEvent) -> Unit, ) sealed interface PaymentStep { @@ -1707,14 +1649,14 @@ sealed interface PaymentError { data class TransactionFailed(val message: String) : PaymentError } -interface PaymentFlowEvents { - fun onAddressChanged(address: String) - fun onConfirmAddress() - fun onConfirmPayment() - fun onAuthenticationResult(success: Boolean, error: String?) - fun onCancel() - fun onDismissError() - fun onDone() +sealed interface PaymentFlowEvent { + data class AddressChanged(val address: String) : PaymentFlowEvent + object ConfirmAddress : PaymentFlowEvent + object ConfirmPayment : PaymentFlowEvent + data class AuthenticationResult(val success: Boolean, val error: String?) : PaymentFlowEvent + object Cancel : PaymentFlowEvent + object DismissError : PaymentFlowEvent + object Done : PaymentFlowEvent } ``` @@ -1723,43 +1665,42 @@ interface PaymentFlowEvents { package io.element.android.features.wallet.impl.payment import androidx.compose.runtime.* -import com.bloxbean.cardano.client.common.model.Networks +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import io.element.android.features.wallet.api.slash.PaymentRecipient import io.element.android.features.wallet.api.slash.PaymentUnit -import io.element.android.features.wallet.impl.biometric.BiometricAuthenticator -import io.element.android.features.wallet.impl.cardano.BlockfrostClient +import io.element.android.features.wallet.impl.cardano.CardanoClient import io.element.android.features.wallet.impl.cardano.CardanoWalletManager import io.element.android.features.wallet.impl.cardano.TransactionBuildError import io.element.android.features.wallet.impl.cardano.TransactionBuilder import io.element.android.features.wallet.impl.storage.CardanoKeyStorage import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import javax.inject.Inject -class PaymentFlowPresenter @Inject constructor( +class PaymentFlowPresenter @AssistedInject constructor( + @Assisted private val roomId: RoomId, + @Assisted private val amount: Double, + @Assisted private val unit: PaymentUnit, + @Assisted private val recipient: PaymentRecipient, private val matrixClient: MatrixClient, private val walletManager: CardanoWalletManager, private val keyStorage: CardanoKeyStorage, private val transactionBuilder: TransactionBuilder, - private val blockfrostClient: BlockfrostClient, + private val cardanoClient: CardanoClient, ) : Presenter { - private var amount: Double = 0.0 - private var unit: PaymentUnit = PaymentUnit.ADA - private var originalRecipient: PaymentRecipient? = null - private var roomId: String? = null - - fun initialize( - roomId: String, - amount: Double, - unit: PaymentUnit, - recipient: PaymentRecipient, - ) { - this.roomId = roomId - this.amount = amount - this.unit = unit - this.originalRecipient = recipient + @AssistedFactory + interface Factory { + fun create( + roomId: RoomId, + amount: Double, + unit: PaymentUnit, + recipient: PaymentRecipient, + ): PaymentFlowPresenter } @Composable @@ -1784,13 +1725,13 @@ class PaymentFlowPresenter @Inject constructor( // Get balance address?.let { - blockfrostClient.getBalance(it).onSuccess { balance -> + cardanoClient.getBalance(it).onSuccess { balance -> senderBalance = balance } } // Check recipient type - when (val recipient = originalRecipient) { + when (recipient) { is PaymentRecipient.CardanoAddress -> { resolvedAddress = recipient.address addressInput = recipient.address @@ -1800,9 +1741,6 @@ class PaymentFlowPresenter @Inject constructor( // Can't resolve in Phase 1 — need manual entry step = PaymentStep.EnterAddress } - null -> { - step = PaymentStep.EnterAddress - } } // Estimate fee @@ -1821,13 +1759,12 @@ class PaymentFlowPresenter @Inject constructor( val feeAda = estimatedFee?.let { it / 1_000_000.0 } val totalAda = feeAda?.let { amountAda + it } - val events = remember { - object : PaymentFlowEvents { - override fun onAddressChanged(address: String) { - addressInput = address + fun handleEvent(event: PaymentFlowEvent) { + when (event) { + is PaymentFlowEvent.AddressChanged -> { + addressInput = event.address } - - override fun onConfirmAddress() { + PaymentFlowEvent.ConfirmAddress -> { if (isValidCardanoAddress(addressInput)) { resolvedAddress = addressInput step = PaymentStep.Confirm @@ -1835,16 +1772,15 @@ class PaymentFlowPresenter @Inject constructor( error = PaymentError.InvalidAddress } } - - override fun onConfirmPayment() { + PaymentFlowEvent.ConfirmPayment -> { step = PaymentStep.Authenticating } - - override fun onAuthenticationResult(success: Boolean, errorMsg: String?) { - if (success) { + is PaymentFlowEvent.AuthenticationResult -> { + if (event.success) { step = PaymentStep.Submitting scope.launch { submitTransaction( + scope = scope, sessionId = sessionId, recipientAddress = resolvedAddress!!, amountLovelace = amountLovelace, @@ -1859,7 +1795,7 @@ class PaymentFlowPresenter @Inject constructor( ) } } else { - error = if (errorMsg?.contains("cancel", ignoreCase = true) == true) { + error = if (event.error?.contains("cancel", ignoreCase = true) == true) { PaymentError.AuthenticationCancelled } else { PaymentError.AuthenticationFailed @@ -1867,17 +1803,14 @@ class PaymentFlowPresenter @Inject constructor( step = PaymentStep.Error } } - - override fun onCancel() { + PaymentFlowEvent.Cancel -> { // Navigator handles back } - - override fun onDismissError() { + PaymentFlowEvent.DismissError -> { error = null step = PaymentStep.Confirm } - - override fun onDone() { + PaymentFlowEvent.Done -> { // Navigator handles finish } } @@ -1887,7 +1820,7 @@ class PaymentFlowPresenter @Inject constructor( step = step, amount = amount, unit = unit, - originalRecipient = originalRecipient ?: PaymentRecipient.CardanoAddress(""), + originalRecipient = recipient, resolvedAddress = resolvedAddress, addressInput = addressInput, estimatedFee = estimatedFee, @@ -1898,11 +1831,12 @@ class PaymentFlowPresenter @Inject constructor( senderBalanceAda = senderBalance?.let { "%.6f".format(it / 1_000_000.0) }, txHash = txHash, error = error, - eventActions = events, + eventSink = ::handleEvent, ) } private suspend fun submitTransaction( + scope: CoroutineScope, sessionId: io.element.android.libraries.matrix.api.core.SessionId, recipientAddress: String, amountLovelace: Long, @@ -1917,11 +1851,13 @@ class PaymentFlowPresenter @Inject constructor( result.onSuccess { hash -> // Send Matrix event - sendPaymentEvent( - txHash = hash, - recipientAddress = recipientAddress, - amountLovelace = amountLovelace, - ) + scope.launch { + sendPaymentEvent( + txHash = hash, + recipientAddress = recipientAddress, + amountLovelace = amountLovelace, + ) + } onSuccess(hash) }.onFailure { throwable -> val paymentError = when (throwable) { @@ -1939,11 +1875,10 @@ class PaymentFlowPresenter @Inject constructor( recipientAddress: String, amountLovelace: Long, ) { - // Send m.payment.cardano event to room - val room = matrixClient.getRoom(io.element.android.libraries.matrix.api.core.RoomId(roomId!!)) + // Send m.payment.cardano event to room - implemented in Task 8 + val room = matrixClient.getRoom(roomId) room?.let { - // This will be implemented in Task 7 - // it.sendPaymentEvent(txHash, recipientAddress, amountLovelace) + // PaymentEventSender handles this } } @@ -1958,7 +1893,6 @@ class PaymentFlowPresenter @Inject constructor( package io.element.android.features.wallet.impl.payment import androidx.compose.foundation.layout.* -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close @@ -1967,7 +1901,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.theme.components.* @@ -1986,7 +1919,7 @@ fun PaymentFlowScreen( title = { Text("Send Payment") }, navigationIcon = { BackButton(onClick = { - state.eventActions.onCancel() + state.eventSink(PaymentFlowEvent.Cancel) onNavigateBack() }) } @@ -2017,7 +1950,7 @@ fun PaymentFlowScreen( ConfirmContent( state = state, onConfirm = { - state.eventActions.onConfirmPayment() + state.eventSink(PaymentFlowEvent.ConfirmPayment) onAuthenticateRequest() }, modifier = Modifier.fillMaxSize(), @@ -2040,7 +1973,7 @@ fun PaymentFlowScreen( SuccessContent( txHash = state.txHash ?: "", onDone = { - state.eventActions.onDone() + state.eventSink(PaymentFlowEvent.Done) onNavigateBack() }, modifier = Modifier.fillMaxSize(), @@ -2050,9 +1983,9 @@ fun PaymentFlowScreen( PaymentStep.Error -> { ErrorContent( error = state.error, - onRetry = { state.eventActions.onDismissError() }, + onRetry = { state.eventSink(PaymentFlowEvent.DismissError) }, onCancel = { - state.eventActions.onCancel() + state.eventSink(PaymentFlowEvent.Cancel) onNavigateBack() }, modifier = Modifier.fillMaxSize(), @@ -2095,7 +2028,7 @@ private fun EnterAddressContent( OutlinedTextField( value = state.addressInput, - onValueChange = { state.eventActions.onAddressChanged(it) }, + onValueChange = { state.eventSink(PaymentFlowEvent.AddressChanged(it)) }, label = { Text("Cardano Address") }, placeholder = { Text("addr1q...") }, modifier = Modifier.fillMaxWidth(), @@ -2109,7 +2042,7 @@ private fun EnterAddressContent( Spacer(modifier = Modifier.weight(1f)) Button( - onClick = { state.eventActions.onConfirmAddress() }, + onClick = { state.eventSink(PaymentFlowEvent.ConfirmAddress) }, modifier = Modifier.fillMaxWidth(), enabled = state.addressInput.isNotBlank(), ) { @@ -2158,7 +2091,7 @@ private fun ConfirmContent( ) { DetailRow("To", state.resolvedAddress?.take(20) + "..." ?: "Unknown") DetailRow("Network Fee", state.estimatedFeeAda?.let { "~$it ADA" } ?: "Calculating...") - Divider() + HorizontalDivider() DetailRow( "Total", state.totalAmountAda?.let { "$it ADA" } ?: "Calculating...", @@ -2361,18 +2294,19 @@ import io.element.android.features.wallet.impl.biometric.BiometricAuthenticator import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.launch @ContributesNode(SessionScope::class) class PaymentFlowNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: PaymentFlowPresenter, + private val presenterFactory: PaymentFlowPresenter.Factory, private val biometricAuthenticator: BiometricAuthenticator, ) : Node(buildContext, plugins = plugins) { data class Inputs( - val roomId: String, + val roomId: RoomId, val amount: Double, val unit: PaymentUnit, val recipient: PaymentRecipient, @@ -2380,8 +2314,8 @@ class PaymentFlowNode @AssistedInject constructor( private val inputs: Inputs = inputs() - init { - presenter.initialize( + private val presenter by lazy { + presenterFactory.create( roomId = inputs.roomId, amount = inputs.amount, unit = inputs.unit, @@ -2407,13 +2341,13 @@ class PaymentFlowNode @AssistedInject constructor( when (result) { BiometricAuthenticator.AuthResult.Success -> { - state.eventActions.onAuthenticationResult(true, null) + state.eventSink(PaymentFlowEvent.AuthenticationResult(true, null)) } is BiometricAuthenticator.AuthResult.Error -> { - state.eventActions.onAuthenticationResult(false, result.message) + state.eventSink(PaymentFlowEvent.AuthenticationResult(false, result.message)) } BiometricAuthenticator.AuthResult.Cancelled -> { - state.eventActions.onAuthenticationResult(false, "cancelled") + state.eventSink(PaymentFlowEvent.AuthenticationResult(false, "cancelled")) } } } @@ -2437,7 +2371,9 @@ class PaymentFlowNode @AssistedInject constructor( 1. **State machine**: Loading → EnterAddress (if needed) → Confirm → Authenticating → Submitting → Success/Error 2. **Biometric trigger**: Called from Node when entering `Authenticating` step 3. **Presenter pattern**: Follows Element X conventions with `@Composable present()` returning state -4. **Node pattern**: Uses Appyx for navigation, `@ContributesNode` for DI +4. **AssistedInject**: Presenter uses `@AssistedInject` for parameters that vary per instance +5. **Event sink pattern**: State includes `eventSink` function for handling events (Element X standard pattern) +6. **Node pattern**: Uses Appyx for navigation, `@ContributesNode` for DI ### Gotchas @@ -2456,13 +2392,13 @@ class PaymentFlowNode @AssistedInject constructor( **Effort:** 2.5 days **Acceptance criteria:** -- [ ] `m.payment.cardano` event sends to room +- [ ] `m.payment.cardano` event sends to room via raw event API - [ ] Payment card renders for sender - [ ] Payment card renders for recipient - [ ] Card shows amount, status, explorer link - [ ] Tapping explorer link opens CardanoScan - [ ] PENDING status updates to CONFIRMED (polling) -- [ ] Unknown/old clients show fallback text +- [ ] Unknown/old clients see event as unknown (graceful degradation) ### Files @@ -2474,29 +2410,25 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * Content for m.payment.cardano events. + * Content for m.payment.cardano custom events. * - * Example JSON: + * This is a proper custom event type, NOT a message with custom msgtype. + * Other clients will see it as an unknown event and gracefully degrade. + * + * Example JSON content: * { - * "msgtype": "m.payment.cardano", - * "body": "Sent 10 ADA", * "chain": "cardano", * "network": "mainnet", * "tx_hash": "abc123...", * "sender_address": "addr1q...", * "recipient_address": "addr1q...", * "amount_lovelace": "10000000", - * "status": "pending" + * "status": "pending", + * "fallback_text": "Sent 10 ADA" * } */ @Serializable data class PaymentEventContent( - @SerialName("msgtype") - val msgtype: String = "m.payment.cardano", - - @SerialName("body") - val body: String, // Fallback text for clients that don't support this - @SerialName("chain") val chain: String = "cardano", @@ -2517,6 +2449,9 @@ data class PaymentEventContent( @SerialName("status") val status: String, // "pending", "confirmed", "failed" + + @SerialName("fallback_text") + val fallbackText: String, // Human-readable fallback ) ``` @@ -2687,24 +2622,25 @@ import io.element.android.features.wallet.api.timeline.PaymentEventContent import io.element.android.libraries.matrix.api.core.UserId import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonPrimitive import javax.inject.Inject class TimelineItemPaymentFactory @Inject constructor( private val json: Json, ) { - fun create( - rawContent: JsonObject, + /** + * Creates a payment content item from raw event JSON. + * Called when the timeline factory encounters UnknownContent with + * event type "m.payment.cardano". + */ + fun createFromRaw( + rawJson: JsonObject, currentUserId: UserId, senderUserId: UserId, ): TimelineItemPaymentContent? { return try { - val msgtype = rawContent["msgtype"]?.jsonPrimitive?.content - if (msgtype != "m.payment.cardano") return null - val content = json.decodeFromJsonElement( PaymentEventContent.serializer(), - rawContent + rawJson ) val amountLovelace = content.amountLovelace.toLongOrNull() ?: return null @@ -2740,30 +2676,32 @@ class TimelineItemPaymentFactory @Inject constructor( ```kotlin package io.element.android.features.wallet.impl.timeline -import io.element.android.features.wallet.impl.cardano.BlockfrostClient -import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.features.wallet.impl.cardano.CardanoClient import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject +import javax.inject.Singleton /** - * Polls Blockfrost to update payment status from PENDING to CONFIRMED. - * Updates the Matrix event when status changes. + * Polls Koios to update payment status from PENDING to CONFIRMED. + * Updates local state when status changes. */ +@Singleton class PaymentStatusPoller @Inject constructor( - private val blockfrostClient: BlockfrostClient, + private val cardanoClient: CardanoClient, ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val _pendingPayments = MutableStateFlow>(emptySet()) val pendingPayments: StateFlow> = _pendingPayments - fun startPolling( - txHash: String, - eventId: String, - room: MatrixRoom, - ) { + private val _confirmedPayments = MutableStateFlow>(emptyMap()) + val confirmedPayments: StateFlow> = _confirmedPayments + + fun startPolling(txHash: String) { + if (txHash in _pendingPayments.value) return + _pendingPayments.value = _pendingPayments.value + txHash scope.launch { @@ -2775,15 +2713,13 @@ class PaymentStatusPoller @Inject constructor( delay(10_000) // 10 seconds between polls attempts++ - val result = blockfrostClient.getTransactionStatus(txHash) + val result = cardanoClient.getTransactionStatus(txHash) result.onSuccess { status -> if (status.confirmed) { confirmed = true _pendingPayments.value = _pendingPayments.value - txHash - - // Update Matrix event with confirmed status - // Note: This requires the ability to edit events or send a relation - // For MVP, we might just update local state + _confirmedPayments.value = _confirmedPayments.value + + (txHash to (status.blockHeight ?: 0L)) } } } @@ -2803,11 +2739,10 @@ class PaymentStatusPoller @Inject constructor( #### Modify: `features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt` -Add payment event handling: +Add payment event handling in the UnknownContent branch: ```kotlin // Add import: import io.element.android.features.wallet.impl.timeline.TimelineItemPaymentFactory -import io.element.android.features.wallet.impl.timeline.TimelineItemPaymentContent // Inject factory: @Inject constructor( @@ -2815,13 +2750,16 @@ import io.element.android.features.wallet.impl.timeline.TimelineItemPaymentConte private val paymentFactory: TimelineItemPaymentFactory, ) -// In create() method, add case for raw JSON content handling: -// When content is UnknownContent, try to parse as payment: +// In create() method, modify UnknownContent handling: is UnknownContent -> { - // Try payment first - val rawJson = /* get raw JSON from event */ - paymentFactory.create(rawJson, currentUserId, senderUserId) - ?: TimelineItemUnknownContent + // Check if this is a payment event we can handle + if (content.eventType == "m.payment.cardano") { + content.rawJson?.let { rawJson -> + paymentFactory.createFromRaw(rawJson, currentUserId, senderUserId) + } ?: TimelineItemUnknownContent + } else { + TimelineItemUnknownContent + } } ``` @@ -2848,11 +2786,17 @@ import io.element.android.features.wallet.api.timeline.PaymentEventContent import io.element.android.libraries.matrix.api.room.MatrixRoom import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement import javax.inject.Inject class PaymentEventSender @Inject constructor( private val json: Json, ) { + companion object { + const val EVENT_TYPE = "m.payment.cardano" + } + suspend fun sendPaymentEvent( room: MatrixRoom, txHash: String, @@ -2860,232 +2804,166 @@ class PaymentEventSender @Inject constructor( recipientAddress: String, amountLovelace: Long, ): Result { + val amountAda = "%.6f".format(amountLovelace / 1_000_000.0) + .trimEnd('0') + .trimEnd('.') + val content = PaymentEventContent( - body = "Sent ${amountLovelace / 1_000_000.0} ADA", txHash = txHash, senderAddress = senderAddress, recipientAddress = recipientAddress, amountLovelace = amountLovelace.toString(), status = "pending", + fallbackText = "Sent $amountAda ADA", ) - // Send as raw JSON event - // This uses the SDK's ability to send arbitrary message content - return room.sendMessage( - body = content.body, - htmlBody = null, - mentions = emptyList(), - ).map { eventId -> - // TODO: Actually send as m.room.message with custom msgtype - // This requires SDK extension (Task 8) - eventId.value - } + val contentJson = json.encodeToJsonElement(content) as JsonObject + + // Send as raw custom event type + return room.sendRawEvent( + eventType = EVENT_TYPE, + content = contentJson, + ) } } ``` ### Key Implementation Details -1. **Event schema**: Custom `msgtype` of `m.payment.cardano` with standard `m.room.message` event type -2. **Fallback text**: `body` field contains human-readable text for clients without payment support -3. **Status tracking**: Initially "pending", updated to "confirmed" via polling -4. **Explorer URL**: CardanoScan for mainnet: `https://cardanoscan.io/transaction/{txHash}` -5. **Factory registration**: Payment factory must be called before falling back to UnknownContent +1. **Custom event type**: `m.payment.cardano` sent via `room.sendRawEvent()` +2. **No formatted_body hack**: Proper custom event type, not smuggled in message content +3. **UnknownContent interception**: Timeline factory checks event type and parses raw JSON +4. **Graceful degradation**: Other clients see it as an unknown event (not a broken message) +5. **Status tracking**: Initially "pending", updated via polling +6. **Explorer URL**: CardanoScan for mainnet: `https://cardanoscan.io/transaction/{txHash}` ### Gotchas -- **Event editing**: Updating status requires event editing or relation events. For MVP, may just track locally. -- **Raw JSON access**: Need access to raw event JSON to parse custom content. SDK may not expose this cleanly. -- **Cross-client rendering**: Other Matrix clients will see fallback `body` text only. +- **Raw JSON access**: Need to access `rawJson` from UnknownContent — verify SDK exposes this +- **Event editing**: Updating status requires event relations. For MVP, track locally. +- **Cross-client rendering**: Other Matrix clients will simply not render the event (graceful) - **Network switch**: `network` field should match (mainnet vs testnet). Don't mix. - **Amount precision**: Store as string to avoid floating point issues with lovelace. --- -## Task 8: SDK Extension — Register `m.payment.cardano` Event Type +## Task 8: SDK Extension — Raw Event Support for `m.payment.cardano` **Blocks:** Task 7 (fully functional) **Blocked by:** Task 1 -**Effort:** 2 days +**Effort:** 1.5 days **Acceptance criteria:** -- [ ] SDK recognizes `m.payment.cardano` msgtype -- [ ] Raw JSON content accessible from Kotlin -- [ ] Can send custom msgtype via SDK +- [ ] SDK can send raw events with custom type via `sendRawEvent()` +- [ ] SDK returns raw JSON content for UnknownContent events - [ ] Event parsing doesn't crash on unknown content - [ ] Existing message types unaffected -### Files +### Analysis: Current SDK Capabilities -#### Analysis: Where Event Types Are Registered +The matrix-rust-sdk (used by Element X) handles custom events via `UnknownContent`. Key questions: -In `matrix-rust-sdk` (the SDK Element X uses), event parsing happens in Rust. The Kotlin bindings expose: +1. **Sending raw events**: Does `MatrixRoom` expose `sendRawEvent()` or equivalent? +2. **Receiving raw JSON**: Does `UnknownContent` expose the raw JSON content? -``` -libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/ -├── EventTimelineItemMapper.kt -├── TimelineEventContentMapper.kt -└── ... -``` +#### Approach: Verify and Extend as Needed -The `TimelineEventContentMapper` converts Rust SDK types to Kotlin types. +**Step 1: Check existing API** -For unknown message types, the SDK returns `OtherState` or falls back gracefully. - -#### Approach: Client-Side Custom Parsing - -Since modifying the Rust SDK is complex and we want Phase 1 to work without forking the SDK: - -1. **Intercept at the Kotlin layer**: After SDK returns timeline items, check for messages with our custom msgtype -2. **Access raw content**: The SDK does provide raw JSON for unknown content -3. **Custom parsing**: Parse `m.payment.cardano` content ourselves - -#### New: `features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/sdk/PaymentEventParser.kt` +In `libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt`: ```kotlin -package io.element.android.features.wallet.impl.sdk +interface MatrixRoom { + // Look for: + suspend fun sendRawEvent(eventType: String, content: JsonObject): Result + // or similar +} +``` -import io.element.android.features.wallet.api.timeline.PaymentEventContent -import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent -import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import javax.inject.Inject +**Step 2: Check UnknownContent** -class PaymentEventParser @Inject constructor( - private val json: Json, -) { - /** - * Checks if a message content is a payment event and parses it. - */ - fun tryParse(content: MessageContent): PaymentEventContent? { - // Check if it's an "other" message type (custom msgtype) - val messageType = content.type - if (messageType !is OtherMessageType) return null - - // Check if msgtype matches - if (messageType.msgtype != "m.payment.cardano") return null - - // Try to parse the raw content - return try { - // The SDK should expose raw content for OtherMessageType - // This might be in messageType.body or a raw JSON field - // Exact API depends on SDK version - - // Attempt to parse from available data - val rawJson = messageType.rawContent ?: return null - json.decodeFromJsonElement(PaymentEventContent.serializer(), rawJson) - } catch (e: Exception) { - null - } +In SDK bindings, check if `UnknownContent` has: +```kotlin +data class UnknownContent( + val eventType: String, + val rawJson: JsonObject?, // Do we have access to this? +) +``` + +#### Implementation Path A: API Already Exists + +If the SDK already exposes these methods (likely), we just need to: +1. Use `sendRawEvent()` in PaymentEventSender +2. Access `rawJson` in TimelineItemContentFactory + +#### Implementation Path B: Minimal SDK Extension + +If raw event sending isn't exposed: + +```kotlin +// In RustMatrixRoom (or via extension) +suspend fun sendRawEvent(eventType: String, content: JsonObject): Result { + return runCatching { + // The Rust SDK has this capability internally + innerRoom.sendRawEvent(eventType, content.toString()) } } ``` -#### Modify: SDK Binding (if raw content not exposed) +### Files to Verify/Modify -If the SDK doesn't expose raw JSON for custom message types, we need a minimal extension. - -**Option A: Fork and patch matrix-rust-sdk-bindings** - -In `crates/matrix-sdk-ffi/src/timeline/content.rs`, add: -```rust -pub struct OtherMessageType { - pub msgtype: String, - pub body: String, - pub raw_content: Option, // ADD THIS -} -``` - -Then regenerate Kotlin bindings. - -**Option B: Use m.room.message with formatted_body hack** - -Store payment JSON in `formatted_body`: -```json -{ - "msgtype": "m.text", - "body": "Sent 10 ADA", - "format": "io.element.payment.cardano", - "formatted_body": "{\"tx_hash\":\"...\", ...}" -} -``` - -This is hacky but works without SDK changes. - -**Option C: Send as State Event** - -Use a custom state event type instead of room message: -- Event type: `com.sulkta.payment` -- State key: transaction ID - -Pro: Full JSON control -Con: State events aren't rendered in timeline by default - -#### Recommended Approach for Phase 1 - -Use **Option B** (formatted_body hack) for MVP: +#### Check: `libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt` +Verify these methods exist or add them: ```kotlin -// PaymentEventSender.kt - Updated -suspend fun sendPaymentEvent( - room: MatrixRoom, - txHash: String, - senderAddress: String, - recipientAddress: String, - amountLovelace: Long, -): Result { - val paymentJson = json.encodeToString(PaymentEventContent( - body = "Sent ${formatAda(amountLovelace)} ADA", - txHash = txHash, - senderAddress = senderAddress, - recipientAddress = recipientAddress, - amountLovelace = amountLovelace.toString(), - status = "pending", - )) +interface MatrixRoom { + // ... existing methods ... - // Send as text message with custom format marker - return room.sendMessage( - body = "Sent ${formatAda(amountLovelace)} ADA", - htmlBody = paymentJson, // Abuse formatted_body for JSON - format = "io.element.payment.cardano", // Custom format marker - mentions = emptyList(), - ) + /** + * Send a raw event with custom event type and content. + * @param eventType The event type (e.g., "m.payment.cardano") + * @param content The event content as JSON + * @return The event ID of the sent event + */ + suspend fun sendRawEvent(eventType: String, content: JsonObject): Result } +``` -// TimelineItemPaymentFactory.kt - Updated parsing -fun create(content: MessageContent, ...): TimelineItemPaymentContent? { - // Check for our custom format - if (content.formattedBody != null && - content.format == "io.element.payment.cardano") { - return try { - val paymentContent = json.decodeFromString( - content.formattedBody!! - ) - // ... convert to TimelineItemPaymentContent - } catch (e: Exception) { - null - } - } - return null +#### Check: `libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt` + +Verify or add implementation: +```kotlin +override suspend fun sendRawEvent(eventType: String, content: JsonObject): Result { + return runCatching { + innerRoom.sendRaw(eventType, content.toString()).await() + }.mapCatching { it.eventId.value } +} +``` + +#### Check: UnknownContent access to raw JSON + +In timeline item mapping, verify we can access raw content: +```kotlin +// TimelineEventContentMapper.kt or similar +is TimelineItemContent.UnknownContent -> { + // Need access to: + content.eventType // The custom event type + content.rawJson // The raw JSON content } ``` ### Key Implementation Details -1. **MVP approach**: Use `formatted_body` to store JSON, with custom `format` marker -2. **Detection**: Check `format` field for our marker before parsing -3. **Fallback**: `body` contains human-readable text for other clients -4. **Future**: Proper SDK extension in Phase 2 +1. **Raw event API**: Use SDK's raw event capability, not message hacks +2. **No Rust SDK fork**: Use existing Kotlin bindings +3. **JSON handling**: Use kotlinx.serialization for JSON +4. **Event type**: `m.payment.cardano` is our custom type namespace ### Gotchas -- **formatted_body parsing**: Other clients will see raw JSON if they render formatted_body. Consider encoding. -- **SDK changes**: If Element updates SDK, our parsing might break. Pin SDK version. -- **Event editing**: To update payment status, we need event editing support. -- **Redaction**: Users can delete payment events. Handle gracefully. -- **E2EE**: Payment events in encrypted rooms still work — content encrypted same as regular messages. +- **SDK version**: Verify against current Element X SDK version +- **Breaking changes**: If SDK updates, verify raw event API still works +- **Type safety**: Raw JSON loses type safety — validate carefully +- **Event size limits**: Matrix has event size limits (~65KB). Payment events are small. --- @@ -3094,11 +2972,11 @@ fun create(content: MessageContent, ...): TimelineItemPaymentContent? { ``` Week 1: ├── Task 1: Module Scaffolding (1 day) ─────────┐ -├── Task 8: SDK Extension (2 days) ─────────────┤ +├── Task 8: SDK Extension (1.5 days) ───────────┤ └── Task 2: Key Storage (3 days) ───────────────┤ │ Week 2: │ -├── Task 3: Blockfrost Client (1.5 days) ───────┤ +├── Task 3: Koios Client (1 day) ───────────────┤ ├── Task 4: Transaction Builder (3 days) ←──────┘ └── Task 5: Slash Command (2 days) @@ -3109,7 +2987,34 @@ Week 3: Buffer: 2 days for integration testing and bug fixes ``` -**Total Estimate: 18 working days (~3.5 weeks)** +**Total Estimate: 17 working days (~3.5 weeks)** + +--- + +## Changes from Previous Version + +### Correction 1: Koios Instead of Blockfrost +- Replaced all Blockfrost references with Koios +- Using `BackendFactory.getKoiosBackendService(Constants.KOIOS_MAINNET_URL)` +- No API key required — Koios public API is free +- Same interface — all transaction building code unchanged +- Removed: `BLOCKFROST_PROJECT_ID`, `blockfrost_project_id` injection, BlockfrostConfig + +### Correction 2: Raw Event Handling Instead of formatted_body Hack +- Using `room.sendRawEvent(eventType, content)` for sending +- Using `UnknownContent.rawJson` interception for receiving +- Proper custom event type: `m.payment.cardano` +- No data smuggled in formatted_body +- Graceful degradation: other clients see unknown event +- Zero Rust SDK changes or forks required + +### Framework Alignment Fixes +- `PaymentFlowPresenter` now uses `@AssistedInject` + `@AssistedFactory` for runtime parameters +- State uses `eventSink: (Event) -> Unit` pattern (Element X standard) +- Events are sealed interface with data classes (not interface with methods) +- Removed `PaymentFlowEvents` interface in favor of event sink pattern +- `PaymentStatusPoller` is `@Singleton` scoped +- Fixed `Divider()` → `HorizontalDivider()` (Material 3) ---