feat(wallet): transaction builder, UTXO selection, and status poller (Task 4)

## What's new

### API module additions
- ProtocolParameters: data class for fee calculation params
- PaymentRequest: transaction request model
- SignedTransaction: signed transaction result model
- TransactionBuilder: interface for building/signing transactions
- PaymentStatusPoller: interface for polling tx confirmation

### CardanoClient updates
- Added getProtocolParameters() to interface
- Implemented in KoiosCardanoClient with retry logic

### Implementation
- DefaultTransactionBuilder: builds and signs transactions using cardano-client-lib
  - Largest-first UTXO selection
  - Fee calculation via protocol parameters
  - Min UTXO validation (1 ADA minimum)
  - Secure key handling (zeroed after use)
- DefaultPaymentStatusPoller: polls Koios for tx confirmation
  - 10s polling interval, 60 attempts max (~10 minutes)
  - Emits TxStatus.PENDING -> CONFIRMED/FAILED flow

### Test module
- FakeTransactionBuilder: configurable success/failure responses
- FakePaymentStatusPoller: configurable status sequences
- Updated FakeCardanoClient with getProtocolParameters()

### Unit tests
- TransactionBuilderTest: UTXO selection, fee calculation, error handling
- PaymentStatusPollerTest: polling behavior, error recovery
This commit is contained in:
Kayos 2026-03-27 10:52:15 -07:00
parent 19637833a6
commit 9439f5a227
15 changed files with 1298 additions and 0 deletions

View file

@ -44,4 +44,13 @@ interface CardanoClient {
* @return Current [TxStatus] of the transaction
*/
suspend fun getTxStatus(txHash: String): Result<TxStatus>
/**
* 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<ProtocolParameters>
}

View file

@ -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,
)

View file

@ -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<TxStatus>
}

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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<SignedTransaction>
}

View file

@ -52,4 +52,5 @@ dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.turbine)
}

View file

@ -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<SignedTransaction> = withContext(Dispatchers.IO) {
Timber.tag(TAG).d("Building transaction: ${request.amountLovelace} lovelace to ${request.toAddress.take(20)}...")
runCatching {
// 1. Validate addresses
validateAddress(request.fromAddress, "sender")
validateAddress(request.toAddress, "recipient")
// 2. Validate amount (minimum 1 ADA)
if (request.amountLovelace < MIN_UTXO_LOVELACE) {
throw CardanoException.ApiException(
message = "Amount too small: minimum is 1 ADA (1,000,000 lovelace)",
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) }
}

View file

@ -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<ProtocolParameters> =
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.
*/

View file

@ -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<TxStatus> = 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)")
}
}

View file

@ -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()
}
}
}

View file

@ -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,
)
}

View file

@ -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<ProtocolParameters> {
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 {

View file

@ -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<String, List<TxStatus>>()
// Default behavior: PENDING → CONFIRMED
var defaultSequence = listOf(TxStatus.PENDING, TxStatus.CONFIRMED)
// Tracking
val polledTransactions = mutableListOf<String>()
var pollCallCount = 0
private set
override fun pollUntilConfirmed(txHash: String): Flow<TxStatus> = 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)
}
}

View file

@ -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<SignedTransaction>? = 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<PaymentRequest>()
var buildAndSignCallCount = 0
private set
override suspend fun buildAndSign(request: PaymentRequest): Result<SignedTransaction> {
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<SignedTransaction>) {
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)
}
}
}