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:
parent
19637833a6
commit
9439f5a227
15 changed files with 1298 additions and 0 deletions
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -52,4 +52,5 @@ dependencies {
|
|||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.test.turbine)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue