diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/CardanoClient.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/CardanoClient.kt index 2d99319234..20940aa73d 100644 --- a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/CardanoClient.kt +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/CardanoClient.kt @@ -44,4 +44,13 @@ interface CardanoClient { * @return Current [TxStatus] of the transaction */ suspend fun getTxStatus(txHash: String): Result + + /** + * Get the current protocol parameters from the network. + * + * Protocol parameters are needed for fee calculation and transaction building. + * + * @return Current [ProtocolParameters] from the latest epoch + */ + suspend fun getProtocolParameters(): Result } diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/PaymentRequest.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/PaymentRequest.kt new file mode 100644 index 0000000000..f9efa37c70 --- /dev/null +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/PaymentRequest.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.api + +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * A request to build and sign a Cardano payment transaction. + * + * @property fromAddress The sender's Cardano address (Bech32) + * @property toAddress The recipient's Cardano address (Bech32) + * @property amountLovelace The amount to send in lovelace (1 ADA = 1,000,000 lovelace) + * @property sessionId The Matrix session ID for key retrieval + */ +data class PaymentRequest( + val fromAddress: String, + val toAddress: String, + val amountLovelace: Long, + val sessionId: SessionId, +) diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/PaymentStatusPoller.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/PaymentStatusPoller.kt new file mode 100644 index 0000000000..b404a8fbae --- /dev/null +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/PaymentStatusPoller.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.api + +import kotlinx.coroutines.flow.Flow + +/** + * Interface for polling transaction confirmation status. + */ +interface PaymentStatusPoller { + /** + * Polls for transaction confirmation status. + * + * Emits [TxStatus] changes as a Flow: + * - Initially PENDING + * - CONFIRMED when transaction is in a block + * - FAILED if confirmation times out or error occurs + * + * Polling behavior: + * - Poll every 10 seconds + * - Maximum 60 attempts (~10 minutes total) + * - Stops when status changes from PENDING + * + * @param txHash The transaction hash to poll + * @return Flow of [TxStatus] changes + */ + fun pollUntilConfirmed(txHash: String): Flow +} diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/ProtocolParameters.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/ProtocolParameters.kt new file mode 100644 index 0000000000..e9faddc71d --- /dev/null +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/ProtocolParameters.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.api + +/** + * Cardano protocol parameters needed for transaction building and fee calculation. + * + * These parameters are set via governance and determine transaction costs + * and constraints on the network. + * + * @property minFeeA The linear fee coefficient (lovelace per byte) + * @property minFeeB The constant fee (base fee in lovelace) + * @property maxTxSize Maximum transaction size in bytes + * @property utxoCostPerByte Cost in lovelace per byte of UTXO storage (for min UTXO calculation) + */ +data class ProtocolParameters( + val minFeeA: Long, + val minFeeB: Long, + val maxTxSize: Int, + val utxoCostPerByte: Long, +) diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/SignedTransaction.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/SignedTransaction.kt new file mode 100644 index 0000000000..43163ce285 --- /dev/null +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/SignedTransaction.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.api + +/** + * A signed Cardano transaction ready for submission. + * + * @property txCbor The CBOR-encoded signed transaction as a hex string + * @property txHash The transaction hash (for tracking) + * @property fee The transaction fee in lovelace + * @property actualAmount The actual amount sent (may differ slightly from requested due to min UTXO rules) + */ +data class SignedTransaction( + val txCbor: String, + val txHash: String, + val fee: Long, + val actualAmount: Long, +) diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/TransactionBuilder.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/TransactionBuilder.kt new file mode 100644 index 0000000000..aba618bec3 --- /dev/null +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/TransactionBuilder.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.api + +/** + * Interface for building and signing Cardano transactions. + * + * The implementation handles: + * - UTXO selection (largest-first coin selection) + * - Fee calculation based on protocol parameters + * - Change output calculation + * - Transaction signing with the spending key + * + * ## Error handling + * The following errors may be returned: + * - [CardanoException.InsufficientFundsException] - Not enough ADA in wallet + * - [CardanoException.InvalidAddressException] - Invalid address format + * - [CardanoException.ApiException] - Various API/build errors + */ +interface TransactionBuilder { + /** + * Builds and signs a payment transaction. + * + * This method will: + * 1. Fetch UTXOs for the sender address + * 2. Select UTXOs to cover amount + fee (largest-first) + * 3. Build the transaction with proper change output + * 4. Retrieve the spending key (triggers biometric prompt) + * 5. Sign the transaction + * 6. Return the signed transaction ready for submission + * + * @param request The payment request details + * @return [SignedTransaction] on success, error on failure + */ + suspend fun buildAndSign(request: PaymentRequest): Result +} diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index 5ea24bb2ff..b8bb1113bf 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -52,4 +52,5 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.test.truth) testImplementation(libs.coroutines.test) + testImplementation(libs.test.turbine) } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/DefaultTransactionBuilder.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/DefaultTransactionBuilder.kt new file mode 100644 index 0000000000..e770a2253f --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/DefaultTransactionBuilder.kt @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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.function.helper.SignerProviders +import com.bloxbean.cardano.client.quicktx.QuickTxBuilder +import com.bloxbean.cardano.client.quicktx.Tx +import dev.zacsweeny.metro.ContributesBinding +import dev.zacsweeny.metro.SessionScope +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.util.Arrays +import javax.inject.Inject + +/** + * 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( + private val cardanoClient: CardanoClient, + private val keyStorage: CardanoKeyStorage, +) : TransactionBuilder { + + 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) */ + 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) + } + + override suspend fun buildAndSign(request: PaymentRequest): Result = 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)", + response = "MIN_UTXO_VIOLATION" + ) + } + + // 3. Fetch and validate UTXOs + val utxos = cardanoClient.getUtxos(request.fromAddress).getOrThrow() + if (utxos.isEmpty()) { + throw CardanoException.InsufficientFundsException( + required = request.amountLovelace, + available = 0L + ) + } + + // 4. Calculate total available and do quick check + val totalAvailable = utxos.sumOf { it.amount } + val estimatedRequired = request.amountLovelace + ROUGH_FEE_ESTIMATE + + if (totalAvailable < estimatedRequired) { + throw CardanoException.InsufficientFundsException( + required = estimatedRequired, + available = totalAvailable + ) + } + + 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, + amountLovelace = request.amountLovelace, + mnemonic = mnemonicString, + ) + + 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.getNetworks(), mnemonic) + + // Build transaction using QuickTx (high-level API) + val tx = Tx() + .payToAddress(recipientAddress, Amount.lovelace(amountLovelace)) + .from(senderAddress) + + val quickTxBuilder = QuickTxBuilder(backendService) + + // Build and sign + val result = quickTxBuilder + .compose(tx) + .withSigner(SignerProviders.signerFrom(account)) + .complete() + + 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 fee = signedTx.body.fee.toLong() + + return SignedTransaction( + txCbor = txBytes.toHexString(), + 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) } +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt index 4b4794673e..1a2149cd41 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt @@ -12,6 +12,7 @@ import dev.zacsweeny.metro.ContributesBinding import dev.zacsweeny.metro.SessionScope 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 kotlinx.coroutines.Dispatchers @@ -165,6 +166,29 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient { } } + override suspend fun getProtocolParameters(): Result = + withRetry("getProtocolParameters") { + withContext(Dispatchers.IO) { + throttleRequest() + + val result = backendService.epochService.protocolParameters + if (result.isSuccessful) { + val params = result.value + Result.success( + ProtocolParameters( + 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, + ) + ) + } else { + Result.failure(parseError(result.response)) + } + } + } + /** * Executes a request with retry logic and exponential backoff. */ diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/PaymentStatusPoller.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/PaymentStatusPoller.kt new file mode 100644 index 0000000000..4778ec5c5d --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/PaymentStatusPoller.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.cardano + +import dev.zacsweeny.metro.AppScope +import dev.zacsweeny.metro.ContributesBinding +import dev.zacsweeny.metro.SingleIn +import io.element.android.features.wallet.api.CardanoClient +import io.element.android.features.wallet.api.PaymentStatusPoller +import io.element.android.features.wallet.api.TxStatus +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import timber.log.Timber +import javax.inject.Inject + +/** + * Default implementation of [PaymentStatusPoller]. + */ +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultPaymentStatusPoller @Inject constructor( + private val cardanoClient: CardanoClient, +) : PaymentStatusPoller { + + companion object { + private const val TAG = "PaymentStatusPoller" + + /** Interval between polls in milliseconds */ + private const val POLL_INTERVAL_MS = 10_000L // 10 seconds + + /** Maximum number of polling attempts */ + private const val MAX_ATTEMPTS = 60 // ~10 minutes total + + /** Initial delay before first poll (give network time to propagate) */ + private const val INITIAL_DELAY_MS = 5_000L // 5 seconds + } + + override fun pollUntilConfirmed(txHash: String): Flow = flow { + Timber.tag(TAG).d("Starting to poll for tx: $txHash") + + // Emit initial PENDING status + emit(TxStatus.PENDING) + + // Wait a bit before first poll (transaction needs time to propagate) + delay(INITIAL_DELAY_MS) + + var attempts = 0 + var lastStatus = TxStatus.PENDING + + while (attempts < MAX_ATTEMPTS && lastStatus == TxStatus.PENDING) { + attempts++ + Timber.tag(TAG).d("Poll attempt $attempts/$MAX_ATTEMPTS for tx: $txHash") + + try { + val result = cardanoClient.getTxStatus(txHash) + + result.fold( + onSuccess = { status -> + if (status != lastStatus) { + Timber.tag(TAG).i("Tx $txHash status changed: $lastStatus -> $status") + lastStatus = status + emit(status) + } + }, + onFailure = { error -> + Timber.tag(TAG).w(error, "Error polling tx $txHash (attempt $attempts)") + // Don't emit FAILED on transient errors, continue polling + } + ) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Exception polling tx $txHash") + // Continue polling on error + } + + // Don't wait if we're done polling + if (lastStatus == TxStatus.PENDING && attempts < MAX_ATTEMPTS) { + delay(POLL_INTERVAL_MS) + } + } + + // If we exhausted attempts without confirmation, mark as potentially failed + if (lastStatus == TxStatus.PENDING) { + Timber.tag(TAG).w("Tx $txHash not confirmed after $MAX_ATTEMPTS attempts") + // Note: Transaction might still confirm later, but we stop polling + // The status remains PENDING, not FAILED, because the tx might still be valid + } + + Timber.tag(TAG).d("Stopped polling for tx: $txHash (final status: $lastStatus)") + } +} diff --git a/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/cardano/PaymentStatusPollerTest.kt b/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/cardano/PaymentStatusPollerTest.kt new file mode 100644 index 0000000000..acf0e0bfb7 --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/cardano/PaymentStatusPollerTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.cardano + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.wallet.api.PaymentStatusPoller +import io.element.android.features.wallet.api.TxStatus +import io.element.android.features.wallet.test.FakeCardanoClient +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [PaymentStatusPoller] implementation. + */ +class PaymentStatusPollerTest { + private lateinit var fakeClient: FakeCardanoClient + private lateinit var poller: PaymentStatusPoller + + @Before + fun setUp() { + fakeClient = FakeCardanoClient() + poller = DefaultPaymentStatusPoller(fakeClient) + } + + @Test + fun `pollUntilConfirmed emits PENDING initially`() = runTest { + // Given + val txHash = "test_tx_hash_abc123" + + // When/Then + poller.pollUntilConfirmed(txHash).test { + val firstStatus = awaitItem() + assertThat(firstStatus).isEqualTo(TxStatus.PENDING) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `pollUntilConfirmed emits CONFIRMED when transaction confirms`() = runTest { + // Given + val txHash = "test_tx_hash_abc123" + fakeClient.transactionStatuses[txHash] = TxStatus.CONFIRMED + + // When/Then + poller.pollUntilConfirmed(txHash).test { + // First emission is always PENDING + assertThat(awaitItem()).isEqualTo(TxStatus.PENDING) + // After first poll, should emit CONFIRMED + assertThat(awaitItem()).isEqualTo(TxStatus.CONFIRMED) + // Flow should complete after confirmation + awaitComplete() + } + } + + @Test + fun `pollUntilConfirmed emits FAILED when transaction fails`() = runTest { + // Given + val txHash = "test_tx_hash_abc123" + fakeClient.transactionStatuses[txHash] = TxStatus.FAILED + + // When/Then + poller.pollUntilConfirmed(txHash).test { + // First emission is always PENDING + assertThat(awaitItem()).isEqualTo(TxStatus.PENDING) + // After first poll, should emit FAILED + assertThat(awaitItem()).isEqualTo(TxStatus.FAILED) + // Flow should complete + awaitComplete() + } + } + + @Test + fun `pollUntilConfirmed calls getTxStatus multiple times while pending`() = runTest { + // Given + val txHash = "test_tx_pending_tx" + // Leave status as PENDING (default) + + // When + poller.pollUntilConfirmed(txHash).test { + // Initial PENDING emission + assertThat(awaitItem()).isEqualTo(TxStatus.PENDING) + + // Simulate confirmation after some time + fakeClient.confirmTransaction(txHash) + + // Should eventually get CONFIRMED + assertThat(awaitItem()).isEqualTo(TxStatus.CONFIRMED) + awaitComplete() + } + + // Then: Multiple status checks should have been made + assertThat(fakeClient.getTxStatusCallCount).isGreaterThan(1) + } + + @Test + fun `pollUntilConfirmed handles network errors gracefully`() = runTest { + // Given + val txHash = "test_tx_network_error" + + // Start with network error, then recover + fakeClient.shouldFailWithNetworkError = true + + // When + poller.pollUntilConfirmed(txHash).test { + // Initial PENDING emission + assertThat(awaitItem()).isEqualTo(TxStatus.PENDING) + + // Disable error and confirm + fakeClient.shouldFailWithNetworkError = false + fakeClient.confirmTransaction(txHash) + + // Should eventually get CONFIRMED despite earlier errors + assertThat(awaitItem()).isEqualTo(TxStatus.CONFIRMED) + awaitComplete() + } + } + + @Test + fun `pollUntilConfirmed only emits on status change`() = runTest { + // Given + val txHash = "test_tx_stable" + // PENDING → PENDING → CONFIRMED + fakeClient.transactionStatuses[txHash] = TxStatus.PENDING + + // When + poller.pollUntilConfirmed(txHash).test { + // First PENDING + assertThat(awaitItem()).isEqualTo(TxStatus.PENDING) + + // Confirm after some polls + fakeClient.confirmTransaction(txHash) + + // Next should be CONFIRMED (not duplicate PENDING) + assertThat(awaitItem()).isEqualTo(TxStatus.CONFIRMED) + awaitComplete() + } + } +} diff --git a/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/cardano/TransactionBuilderTest.kt b/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/cardano/TransactionBuilderTest.kt new file mode 100644 index 0000000000..0009ded3ef --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/io/element/android/features/wallet/impl/cardano/TransactionBuilderTest.kt @@ -0,0 +1,383 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.cardano + +import com.google.common.truth.Truth.assertThat +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.Utxo +import io.element.android.features.wallet.test.FakeCardanoClient +import io.element.android.features.wallet.test.FakeTransactionBuilder +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [TransactionBuilder] implementations. + * + * Tests cover: + * - UTXO selection logic (sufficient, insufficient, exact amount, multiple UTXOs) + * - Fee calculation validation + * - Error handling (insufficient funds, invalid address, etc.) + * - FakeTransactionBuilder behavior for presenter tests + */ +class TransactionBuilderTest { + private lateinit var fakeClient: FakeCardanoClient + private lateinit var fakeBuilder: FakeTransactionBuilder + + private val testSessionId = SessionId("@test:matrix.org") + private val senderAddress = FakeCardanoClient.TEST_ADDRESS + private val recipientAddress = "addr_test1qp2fg..." + "a".repeat(80) // Valid-length address + + @Before + fun setUp() { + fakeClient = FakeCardanoClient() + fakeBuilder = FakeTransactionBuilder() + } + + // ==================== FakeTransactionBuilder Tests ==================== + + @Test + fun `FakeTransactionBuilder returns success by default`() = runTest { + // Given + val request = createPaymentRequest(10_000_000L) // 10 ADA + + // When + val result = fakeBuilder.buildAndSign(request) + + // Then + assertThat(result.isSuccess).isTrue() + val tx = result.getOrNull()!! + assertThat(tx.txHash).startsWith("fake_tx_") + assertThat(tx.fee).isEqualTo(180_000L) // Default fee + assertThat(tx.actualAmount).isEqualTo(10_000_000L) + assertThat(tx.txCbor).isNotEmpty() + } + + @Test + fun `FakeTransactionBuilder tracks calls correctly`() = runTest { + // Given + val request1 = createPaymentRequest(5_000_000L) + val request2 = createPaymentRequest(10_000_000L) + + // When + fakeBuilder.buildAndSign(request1) + fakeBuilder.buildAndSign(request2) + + // Then + assertThat(fakeBuilder.buildAndSignCallCount).isEqualTo(2) + assertThat(fakeBuilder.buildAndSignCalls).hasSize(2) + assertThat(fakeBuilder.buildAndSignCalls[0].amountLovelace).isEqualTo(5_000_000L) + assertThat(fakeBuilder.buildAndSignCalls[1].amountLovelace).isEqualTo(10_000_000L) + } + + @Test + fun `FakeTransactionBuilder returns insufficient funds error when configured`() = runTest { + // Given + fakeBuilder.givenInsufficientFunds(available = 5_000_000L, required = 10_000_000L) + val request = createPaymentRequest(10_000_000L) + + // When + val result = fakeBuilder.buildAndSign(request) + + // Then + assertThat(result.isFailure).isTrue() + val exception = result.exceptionOrNull() as CardanoException.InsufficientFundsException + assertThat(exception.available).isEqualTo(5_000_000L) + assertThat(exception.required).isEqualTo(10_000_000L) + } + + @Test + fun `FakeTransactionBuilder returns invalid address error when configured`() = runTest { + // Given + val badAddress = "invalid_address" + fakeBuilder.givenInvalidAddress(badAddress) + val request = createPaymentRequest(10_000_000L) + + // When + val result = fakeBuilder.buildAndSign(request) + + // Then + assertThat(result.isFailure).isTrue() + val exception = result.exceptionOrNull() as CardanoException.InvalidAddressException + assertThat(exception.address).isEqualTo(badAddress) + } + + @Test + fun `FakeTransactionBuilder returns network error when configured`() = runTest { + // Given + fakeBuilder.givenNetworkError("Connection timeout") + val request = createPaymentRequest(10_000_000L) + + // When + val result = fakeBuilder.buildAndSign(request) + + // Then + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(CardanoException.NetworkException::class.java) + } + + @Test + fun `FakeTransactionBuilder can return custom result`() = runTest { + // Given + val customTx = SignedTransaction( + txCbor = "custom_cbor", + txHash = "custom_hash_abc123", + fee = 250_000L, + actualAmount = 7_500_000L, + ) + fakeBuilder.givenResult(Result.success(customTx)) + val request = createPaymentRequest(7_500_000L) + + // When + val result = fakeBuilder.buildAndSign(request) + + // Then + assertThat(result.isSuccess).isTrue() + val tx = result.getOrNull()!! + assertThat(tx.txHash).isEqualTo("custom_hash_abc123") + assertThat(tx.fee).isEqualTo(250_000L) + } + + @Test + fun `FakeTransactionBuilder verifyBuildAndSignCalled works correctly`() = runTest { + // Given + val request = createPaymentRequest(10_000_000L) + fakeBuilder.buildAndSign(request) + + // Then + assertThat(fakeBuilder.verifyBuildAndSignCalled(fromAddress = senderAddress)).isTrue() + assertThat(fakeBuilder.verifyBuildAndSignCalled(amountLovelace = 10_000_000L)).isTrue() + assertThat(fakeBuilder.verifyBuildAndSignCalled(amountLovelace = 99_999_999L)).isFalse() + } + + @Test + fun `FakeTransactionBuilder reset clears all state`() = runTest { + // Given + fakeBuilder.buildAndSign(createPaymentRequest(10_000_000L)) + fakeBuilder.givenInsufficientFunds(1L, 2L) + + // When + fakeBuilder.reset() + + // Then + assertThat(fakeBuilder.buildAndSignCallCount).isEqualTo(0) + assertThat(fakeBuilder.buildAndSignCalls).isEmpty() + assertThat(fakeBuilder.shouldSucceed).isTrue() + assertThat(fakeBuilder.errorToThrow).isNull() + } + + @Test + fun `FakeTransactionBuilder companion creates success builder`() = runTest { + // Given + val builder = FakeTransactionBuilder.success(fee = 200_000L) + + // When + val result = builder.buildAndSign(createPaymentRequest(5_000_000L)) + + // Then + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()!!.fee).isEqualTo(200_000L) + } + + @Test + fun `FakeTransactionBuilder companion creates insufficient funds builder`() = runTest { + // Given + val builder = FakeTransactionBuilder.insufficientFunds( + available = 1_000_000L, + required = 10_000_000L + ) + + // When + val result = builder.buildAndSign(createPaymentRequest(10_000_000L)) + + // Then + assertThat(result.isFailure).isTrue() + val exception = result.exceptionOrNull() as CardanoException.InsufficientFundsException + assertThat(exception.available).isEqualTo(1_000_000L) + } + + // ==================== UTXO Selection Tests ==================== + // These test the FakeCardanoClient UTXO setup logic + + @Test + fun `UTXO selection - single UTXO covers amount`() = runTest { + // Given + val balance = 20_000_000L // 20 ADA + val utxos = listOf( + createUtxo("tx1", 0, 20_000_000L) + ) + fakeClient.balances[senderAddress] = balance + fakeClient.utxos[senderAddress] = utxos + + // When + val result = fakeClient.getUtxos(senderAddress) + + // Then + assertThat(result.isSuccess).isTrue() + val fetchedUtxos = result.getOrNull()!! + assertThat(fetchedUtxos).hasSize(1) + assertThat(fetchedUtxos.sumOf { it.amount }).isGreaterThan(10_000_000L + 200_000L) + } + + @Test + fun `UTXO selection - multiple UTXOs needed to cover amount`() = runTest { + // Given: 3 small UTXOs that together cover the amount + val utxos = listOf( + createUtxo("tx1", 0, 3_000_000L), + createUtxo("tx2", 0, 4_000_000L), + createUtxo("tx3", 0, 5_000_000L), + ) + fakeClient.balances[senderAddress] = 12_000_000L + fakeClient.utxos[senderAddress] = utxos + + // When + val result = fakeClient.getUtxos(senderAddress) + + // Then + assertThat(result.isSuccess).isTrue() + val fetchedUtxos = result.getOrNull()!! + assertThat(fetchedUtxos).hasSize(3) + assertThat(fetchedUtxos.sumOf { it.amount }).isEqualTo(12_000_000L) + } + + @Test + fun `UTXO selection - exact amount matches available`() = runTest { + // Given: Exact amount (plus estimated fee) equals total UTXOs + val balance = 10_200_000L // 10.2 ADA (covers 10 ADA + ~200k fee) + fakeClient.setupWallet(senderAddress, balance) + + // When + val utxosResult = fakeClient.getUtxos(senderAddress) + + // Then + assertThat(utxosResult.isSuccess).isTrue() + assertThat(utxosResult.getOrNull()!!.sumOf { it.amount }).isEqualTo(balance) + } + + @Test + fun `UTXO selection - insufficient funds returns empty or low balance`() = runTest { + // Given: Not enough balance + val balance = 500_000L // 0.5 ADA - not enough for 10 ADA tx + fakeClient.setupWallet(senderAddress, balance) + + // When + val utxosResult = fakeClient.getUtxos(senderAddress) + + // Then + assertThat(utxosResult.isSuccess).isTrue() + val total = utxosResult.getOrNull()!!.sumOf { it.amount } + // Transaction builder would reject this as insufficient + assertThat(total).isLessThan(10_000_000L) + } + + @Test + fun `UTXO selection - no UTXOs available`() = runTest { + // Given: Address with no UTXOs + val emptyAddress = "addr_test1_empty_wallet" + + // When + val result = fakeClient.getUtxos(emptyAddress) + + // Then + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()).isEmpty() + } + + // ==================== Fee Calculation Tests ==================== + + @Test + fun `fee calculation uses protocol parameters`() = runTest { + // Given + fakeClient.protocolParameters = fakeClient.protocolParameters.copy( + minFeeA = 44L, + minFeeB = 155381L, + ) + + // When + val params = fakeClient.getProtocolParameters().getOrNull()!! + + // Then + assertThat(params.minFeeA).isEqualTo(44L) + assertThat(params.minFeeB).isEqualTo(155381L) + // Fee formula: fee = minFeeA * txSize + minFeeB + // For ~300 byte tx: 44 * 300 + 155381 = 168,581 lovelace + } + + @Test + fun `getProtocolParameters call count tracked`() = runTest { + // When + fakeClient.getProtocolParameters() + fakeClient.getProtocolParameters() + + // Then + assertThat(fakeClient.getProtocolParametersCallCount).isEqualTo(2) + } + + @Test + fun `getProtocolParameters fails on network error`() = runTest { + // Given + fakeClient.shouldFailWithNetworkError = true + + // When + val result = fakeClient.getProtocolParameters() + + // Then + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(CardanoException.NetworkException::class.java) + } + + // ==================== Error Type Tests ==================== + + @Test + fun `InsufficientFundsException contains correct amounts`() { + // Given + val exception = CardanoException.InsufficientFundsException( + required = 15_000_000L, + available = 10_000_000L, + ) + + // Then + assertThat(exception.required).isEqualTo(15_000_000L) + assertThat(exception.available).isEqualTo(10_000_000L) + assertThat(exception.message).contains("15000000") + assertThat(exception.message).contains("10000000") + } + + @Test + fun `InvalidAddressException contains address`() { + // Given + val badAddress = "not_a_valid_cardano_address" + val exception = CardanoException.InvalidAddressException(badAddress) + + // Then + assertThat(exception.address).isEqualTo(badAddress) + assertThat(exception.message).contains(badAddress) + } + + // ==================== Helper Methods ==================== + + private fun createPaymentRequest(amountLovelace: Long) = PaymentRequest( + fromAddress = senderAddress, + toAddress = recipientAddress, + amountLovelace = amountLovelace, + sessionId = testSessionId, + ) + + private fun createUtxo( + txHash: String, + outputIndex: Int, + amount: Long, + ) = Utxo( + txHash = txHash.padEnd(64, '0'), + outputIndex = outputIndex, + amount = amount, + address = senderAddress, + ) +} diff --git a/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeCardanoClient.kt b/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeCardanoClient.kt index 7fd0b169f2..194da7401b 100644 --- a/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeCardanoClient.kt +++ b/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeCardanoClient.kt @@ -8,6 +8,7 @@ package io.element.android.features.wallet.test 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 @@ -33,6 +34,14 @@ class FakeCardanoClient : CardanoClient { var submitShouldFail = false var submitErrorMessage: String? = null + // Protocol parameters (configurable) + var protocolParameters = ProtocolParameters( + minFeeA = 44L, + minFeeB = 155381L, + maxTxSize = 16384, + utxoCostPerByte = 4310L, + ) + // Tracking for verification var getBalanceCallCount = 0 private set @@ -42,6 +51,8 @@ class FakeCardanoClient : CardanoClient { private set var getTxStatusCallCount = 0 private set + var getProtocolParametersCallCount = 0 + private set /** * Represents a submitted transaction for testing. @@ -121,6 +132,19 @@ class FakeCardanoClient : CardanoClient { return Result.success(status) } + override suspend fun getProtocolParameters(): Result { + getProtocolParametersCallCount++ + + if (shouldFailWithNetworkError) { + return Result.failure(CardanoException.NetworkException("Simulated network error")) + } + if (shouldFailWithRateLimit) { + return Result.failure(CardanoException.RateLimitException(retryAfterMs = 1000L)) + } + + return Result.success(protocolParameters) + } + // Helper methods for test setup /** @@ -165,6 +189,13 @@ class FakeCardanoClient : CardanoClient { getUtxosCallCount = 0 submitTxCallCount = 0 getTxStatusCallCount = 0 + getProtocolParametersCallCount = 0 + protocolParameters = ProtocolParameters( + minFeeA = 44L, + minFeeB = 155381L, + maxTxSize = 16384, + utxoCostPerByte = 4310L, + ) } companion object { diff --git a/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakePaymentStatusPoller.kt b/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakePaymentStatusPoller.kt new file mode 100644 index 0000000000..1190e9f001 --- /dev/null +++ b/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakePaymentStatusPoller.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.test + +import io.element.android.features.wallet.api.PaymentStatusPoller +import io.element.android.features.wallet.api.TxStatus +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * Fake implementation of [PaymentStatusPoller] for testing. + * + * Allows configuring the sequence of statuses to emit for each transaction. + */ +class FakePaymentStatusPoller : PaymentStatusPoller { + + // Configurable status sequences per transaction + private val statusSequences = mutableMapOf>() + + // Default behavior: PENDING → CONFIRMED + var defaultSequence = listOf(TxStatus.PENDING, TxStatus.CONFIRMED) + + // Tracking + val polledTransactions = mutableListOf() + var pollCallCount = 0 + private set + + override fun pollUntilConfirmed(txHash: String): Flow = flow { + pollCallCount++ + polledTransactions.add(txHash) + + val sequence = statusSequences[txHash] ?: defaultSequence + for (status in sequence) { + emit(status) + } + } + + /** + * Configures the status sequence for a specific transaction. + */ + fun givenStatusSequence(txHash: String, vararg statuses: TxStatus) { + statusSequences[txHash] = statuses.toList() + } + + /** + * Configures a transaction to confirm immediately. + */ + fun givenConfirmsImmediately(txHash: String) { + statusSequences[txHash] = listOf(TxStatus.PENDING, TxStatus.CONFIRMED) + } + + /** + * Configures a transaction to fail. + */ + fun givenFails(txHash: String) { + statusSequences[txHash] = listOf(TxStatus.PENDING, TxStatus.FAILED) + } + + /** + * Configures a transaction to stay pending indefinitely. + */ + fun givenStaysPending(txHash: String) { + statusSequences[txHash] = listOf(TxStatus.PENDING) + } + + /** + * Resets all state. + */ + fun reset() { + statusSequences.clear() + polledTransactions.clear() + pollCallCount = 0 + defaultSequence = listOf(TxStatus.PENDING, TxStatus.CONFIRMED) + } +} diff --git a/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeTransactionBuilder.kt b/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeTransactionBuilder.kt new file mode 100644 index 0000000000..789ea57452 --- /dev/null +++ b/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeTransactionBuilder.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.test + +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 + +/** + * Fake implementation of [TransactionBuilder] for testing. + * + * Provides configurable success/failure responses and tracks calls + * for assertion in presenter tests. + */ +class FakeTransactionBuilder : TransactionBuilder { + + // Configurable responses + var nextResult: Result? = null + var shouldSucceed = true + var errorToThrow: CardanoException? = null + + // Default successful response + var defaultFee = 180_000L + var defaultTxHashPrefix = "fake_tx_" + + // Tracking for verification + val buildAndSignCalls = mutableListOf() + var buildAndSignCallCount = 0 + private set + + override suspend fun buildAndSign(request: PaymentRequest): Result { + buildAndSignCallCount++ + buildAndSignCalls.add(request) + + // Return configured result if set + nextResult?.let { return it } + + // Return error if configured + errorToThrow?.let { return Result.failure(it) } + + // Return failure if shouldSucceed is false + if (!shouldSucceed) { + return Result.failure( + CardanoException.ApiException( + message = "Simulated build failure", + response = "FAKE_ERROR" + ) + ) + } + + // Return successful transaction + val txHash = "$defaultTxHashPrefix${System.currentTimeMillis()}_$buildAndSignCallCount" + return Result.success( + SignedTransaction( + txCbor = generateFakeCbor(request), + txHash = txHash, + fee = defaultFee, + actualAmount = request.amountLovelace, + ) + ) + } + + /** + * Configures the builder to return a successful transaction. + */ + fun givenSuccess(fee: Long = defaultFee) { + shouldSucceed = true + errorToThrow = null + nextResult = null + defaultFee = fee + } + + /** + * Configures the builder to fail with insufficient funds error. + */ + fun givenInsufficientFunds(available: Long, required: Long) { + errorToThrow = CardanoException.InsufficientFundsException( + required = required, + available = available + ) + nextResult = null + } + + /** + * Configures the builder to fail with invalid address error. + */ + fun givenInvalidAddress(address: String) { + errorToThrow = CardanoException.InvalidAddressException(address) + nextResult = null + } + + /** + * Configures the builder to fail with a network error. + */ + fun givenNetworkError(message: String = "Network error") { + errorToThrow = CardanoException.NetworkException(message) + nextResult = null + } + + /** + * Configures the builder to return a specific result. + */ + fun givenResult(result: Result) { + nextResult = result + errorToThrow = null + } + + /** + * Gets the most recent build request, if any. + */ + fun getLastRequest(): PaymentRequest? = buildAndSignCalls.lastOrNull() + + /** + * Verifies that buildAndSign was called with specific parameters. + */ + fun verifyBuildAndSignCalled( + fromAddress: String? = null, + toAddress: String? = null, + amountLovelace: Long? = null, + ): Boolean { + return buildAndSignCalls.any { request -> + (fromAddress == null || request.fromAddress == fromAddress) && + (toAddress == null || request.toAddress == toAddress) && + (amountLovelace == null || request.amountLovelace == amountLovelace) + } + } + + /** + * Resets all state and counters. + */ + fun reset() { + nextResult = null + shouldSucceed = true + errorToThrow = null + defaultFee = 180_000L + buildAndSignCalls.clear() + buildAndSignCallCount = 0 + } + + /** + * Generates fake CBOR data for testing. + */ + private fun generateFakeCbor(request: PaymentRequest): String { + // Generate a predictable fake CBOR hex string + // In real implementation this would be actual CBOR + val seed = request.hashCode() + return buildString { + repeat(200) { + append("%02x".format((seed + it) and 0xFF)) + } + } + } + + companion object { + /** Creates a FakeTransactionBuilder configured for success */ + fun success(fee: Long = 180_000L) = FakeTransactionBuilder().apply { + givenSuccess(fee) + } + + /** Creates a FakeTransactionBuilder configured to fail with insufficient funds */ + fun insufficientFunds(available: Long, required: Long) = FakeTransactionBuilder().apply { + givenInsufficientFunds(available, required) + } + } +}