feat(wallet): /pay slash command parser and composer integration (Task 5)

Implements Task 5 of Phase 1:

New files:
- ParsedPayCommand.kt: Sealed interface for parse results
  - WithAddressRecipient: Pay to Cardano address
  - WithMatrixRecipient: Pay to Matrix user (requires lookup)
  - AmountOnly: Amount specified, prompt for recipient
  - Empty: Open payment flow with no prefilled data
  - ParseError: Parse error with human-readable reason

- SlashCommandParser.kt: Full /pay command parser
  - Handles: /pay, /pay 10, /pay 10 ADA, /pay 10 tADA
  - Matrix recipients: /pay 10 ADA @user:server
  - Cardano addresses: /pay 10 ADA addr1...
  - Validates amounts (decimal support, max supply check)
  - Validates addresses (prefix, length, network match)
  - Comprehensive error messages

- SlashCommandParserTest.kt: 40+ unit tests covering all patterns

Modified files:
- ResolvedSuggestion.kt: Added Command type for slash commands
- SuggestionsProcessor.kt: /pay shows as autocomplete suggestion
- MarkdownTextEditorState.kt: Command insertion in text editor
- MessageComposerPresenter.kt: Command handling in InsertSuggestion

Note: MessageComposerPresenter sendMessage interception deferred to
Task 6 (requires PaymentFlowPresenter for navigation).
This commit is contained in:
Kayos 2026-03-27 10:38:21 -07:00
parent 880454847e
commit db4c262b27
18 changed files with 1935 additions and 6 deletions

View file

@ -64,5 +64,33 @@ Development Android emulator is live and available:
Connect via: `adb connect 192.168.0.5:5555` Connect via: `adb connect 192.168.0.5:5555`
---
## Task 5: /pay Slash Command Parser + SuggestionsProcessor Extension
### Completed
- ✅ `ParsedPayCommand.kt` - Sealed interface for parse results (WithAddressRecipient, WithMatrixRecipient, AmountOnly, Empty, ParseError)
- ✅ `SlashCommandParser.kt` - Full parser implementation with:
- Amount parsing (integers, decimals, up to 6 decimal places for lovelace precision)
- Unit support (ADA, tADA for testnet, lovelace)
- Matrix user ID validation (@user:server format)
- Cardano address validation (addr1/addr_test1 prefixes, length checks, network mismatch detection)
- Comprehensive error messages
- ✅ `ResolvedSuggestion.kt` - Added `Command(command: String, description: String)` type
- ✅ `SuggestionsProcessor.kt` - Added /pay command suggestion with filtering
- ✅ `MarkdownTextEditorState.kt` - Added Command case to insertSuggestion()
- ✅ `MessageComposerPresenter.kt` - Added Command handling in InsertSuggestion event
- ✅ `SlashCommandParserTest.kt` - Comprehensive unit tests (40+ test cases)
### What's Still Needed (Task 6)
- ⚠️ MessageComposerPresenter interception of /pay on send (requires PaymentFlowPresenter from Task 6)
- ⚠️ Navigation to payment flow when /pay is sent
- ⚠️ Integration with PaymentFlowNode for actual payment execution
### Testing Notes
- Tests use plain JUnit with Truth assertions
- Parser handles edge cases: whitespace, case sensitivity, decimal precision, network mismatches
- Testnet support via `tADA` unit or `addr_test1` addresses
--- ---
*Last updated: 2026-03-27* *Last updated: 2026-03-27*

View file

@ -340,6 +340,11 @@ class MessageComposerPresenter(
val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link) richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
} }
is ResolvedSuggestion.Command -> {
// Insert the command text with a trailing space
richTextEditorState.replaceText("${suggestion.command} ")
suggestionSearchTrigger.value = null
}
} }
} else if (markdownTextEditorState.currentSuggestion != null) { } else if (markdownTextEditorState.currentSuggestion != null) {
markdownTextEditorState.insertSuggestion( markdownTextEditorState.insertSuggestion(

View file

@ -69,7 +69,17 @@ class SuggestionsProcessor {
) )
} }
} }
SuggestionType.Command, SuggestionType.Command -> {
// Return available slash commands filtered by user input
val commands = listOf(
ResolvedSuggestion.Command("/pay", "Send ADA to someone"),
)
commands.filter { command ->
// Filter by what user has typed after /
command.command.contains(suggestion.text, ignoreCase = true) ||
suggestion.text.isEmpty()
}
}
SuggestionType.Emoji, SuggestionType.Emoji,
is SuggestionType.Custom -> { is SuggestionType.Custom -> {
// Clear suggestions // Clear suggestions

View file

@ -0,0 +1,47 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.api
/**
* Client interface for interacting with the Cardano blockchain.
*
* All methods are suspend functions and return [Result] to handle errors gracefully.
* Implementations should handle retries, rate limiting, and network errors internally.
*/
interface CardanoClient {
/**
* Get the balance (in lovelace) for a given Cardano address.
*
* @param address Bech32 Cardano address (addr1... or addr_test1...)
* @return Balance in lovelace (1 ADA = 1,000,000 lovelace)
*/
suspend fun getBalance(address: String): Result<Long>
/**
* Get all unspent transaction outputs (UTxOs) for a given address.
*
* @param address Bech32 Cardano address
* @return List of [Utxo] objects representing available outputs
*/
suspend fun getUtxos(address: String): Result<List<Utxo>>
/**
* Submit a signed transaction to the Cardano network.
*
* @param signedTxCbor CBOR-encoded signed transaction as hex string
* @return Transaction hash on success
*/
suspend fun submitTx(signedTxCbor: String): Result<String>
/**
* Get the current status of a transaction.
*
* @param txHash Transaction hash to query
* @return Current [TxStatus] of the transaction
*/
suspend fun getTxStatus(txHash: String): Result<TxStatus>
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.api
/**
* Base exception for Cardano-related errors.
*/
sealed class CardanoException(
override val message: String,
override val cause: Throwable? = null,
) : Exception(message, cause) {
/**
* Network connectivity or API error.
*/
class NetworkException(
message: String,
val statusCode: Int? = null,
cause: Throwable? = null,
) : CardanoException(message, cause)
/**
* Rate limit exceeded (HTTP 429).
*/
class RateLimitException(
message: String = "Rate limit exceeded",
val retryAfterMs: Long? = null,
) : CardanoException(message)
/**
* Invalid Cardano address format.
*/
class InvalidAddressException(
val address: String,
) : CardanoException("Invalid Cardano address: $address")
/**
* Transaction not found on chain.
*/
class TransactionNotFoundException(
val txHash: String,
) : CardanoException("Transaction not found: $txHash")
/**
* Transaction submission failed.
*/
class SubmissionFailedException(
message: String,
val errorCode: String? = null,
cause: Throwable? = null,
) : CardanoException(message, cause)
/**
* Insufficient funds to complete transaction.
*/
class InsufficientFundsException(
val required: Long,
val available: Long,
) : CardanoException("Insufficient funds: required $required lovelace, available $available lovelace")
/**
* Generic API error for unexpected responses.
*/
class ApiException(
message: String,
val response: String? = null,
cause: Throwable? = null,
) : CardanoException(message, cause)
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.api
/**
* Transaction confirmation status on the Cardano blockchain.
*/
enum class TxStatus {
/** Transaction submitted but not yet confirmed in a block. */
PENDING,
/** Transaction confirmed in at least one block. */
CONFIRMED,
/** Transaction failed or was rejected by the network. */
FAILED,
}

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
/**
* Represents an unspent transaction output (UTxO) on Cardano.
*
* @property txHash The transaction hash where this UTxO was created.
* @property outputIndex The index of this output within the transaction.
* @property amount The amount in lovelace (1 ADA = 1,000,000 lovelace).
* @property address The address holding this UTxO.
*/
data class Utxo(
val txHash: String,
val outputIndex: Int,
val amount: Long,
val address: String,
)

View file

@ -0,0 +1,86 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.cardano
/**
* Cardano network type.
*/
enum class CardanoNetwork {
TESTNET,
MAINNET,
}
/**
* Centralized network configuration for the Cardano wallet.
*
* To switch networks, change [NETWORK] to [CardanoNetwork.MAINNET].
* All derived values (network ID, API URLs) will update automatically.
*
* **Current configuration: TESTNET (preprod)**
*/
object CardanoNetworkConfig {
/**
* SWAP THIS VALUE TO SWITCH NETWORKS
*
* Set to [CardanoNetwork.TESTNET] for development/testing.
* Set to [CardanoNetwork.MAINNET] for production.
*/
val NETWORK: CardanoNetwork = CardanoNetwork.TESTNET
/**
* Cardano network ID.
* - Testnet (preprod): 0
* - Mainnet: 1
*/
val NETWORK_ID: Int = when (NETWORK) {
CardanoNetwork.TESTNET -> 0
CardanoNetwork.MAINNET -> 1
}
/**
* Koios API base URL for the configured network.
* Koios is a decentralized API layer for Cardano requiring no API key.
*
* Rate limits: 100 req/10s for anonymous users.
*/
val KOIOS_BASE_URL: String = when (NETWORK) {
CardanoNetwork.TESTNET -> "https://preprod.koios.rest/api/v1"
CardanoNetwork.MAINNET -> "https://api.koios.rest/api/v1"
}
/**
* CardanoScan explorer URL for viewing transactions.
*/
val EXPLORER_BASE_URL: String = when (NETWORK) {
CardanoNetwork.TESTNET -> "https://preprod.cardanoscan.io"
CardanoNetwork.MAINNET -> "https://cardanoscan.io"
}
/**
* Bech32 address prefix for the configured network.
*/
val ADDRESS_PREFIX: String = when (NETWORK) {
CardanoNetwork.TESTNET -> "addr_test1"
CardanoNetwork.MAINNET -> "addr1"
}
/**
* Human-readable network name.
*/
val NETWORK_NAME: String = when (NETWORK) {
CardanoNetwork.TESTNET -> "Preprod Testnet"
CardanoNetwork.MAINNET -> "Mainnet"
}
/**
* Returns the Networks instance for cardano-client-lib.
*/
fun getNetworks(): com.bloxbean.cardano.client.common.model.Networks = when (NETWORK) {
CardanoNetwork.TESTNET -> com.bloxbean.cardano.client.common.model.Networks.preprod()
CardanoNetwork.MAINNET -> com.bloxbean.cardano.client.common.model.Networks.mainnet()
}
}

View file

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

View file

@ -0,0 +1,269 @@
/*
* 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.backend.api.BackendService
import com.bloxbean.cardano.client.backend.factory.BackendFactory
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.TxStatus
import io.element.android.features.wallet.api.Utxo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
/**
* Cardano blockchain client using the Koios public API.
*
* Koios is a decentralized API layer for Cardano that requires no API key.
* Rate limits: 100 requests per 10 seconds for anonymous users.
*
* Features:
* - Automatic retry with exponential backoff (3 attempts)
* - Rate limit handling with backoff
* - Network error recovery
*
* @see <a href="https://api.koios.rest/">Koios API Documentation</a>
*/
@ContributesBinding(SessionScope::class)
class KoiosCardanoClient @Inject constructor() : CardanoClient {
companion object {
private const val TAG = "KoiosCardanoClient"
private const val MAX_RETRIES = 3
private const val INITIAL_BACKOFF_MS = 1000L
private const val MAX_BACKOFF_MS = 10000L
// Rate limiting: 100 req/10s = 1 req per 100ms minimum
private const val MIN_REQUEST_INTERVAL_MS = 100L
}
private val backendService: BackendService by lazy {
Timber.tag(TAG).d("Initializing Koios backend for ${CardanoNetworkConfig.NETWORK_NAME}")
BackendFactory.getKoiosBackendService(CardanoNetworkConfig.KOIOS_BASE_URL)
}
// Simple rate limiting via mutex and timestamp tracking
private val rateLimitMutex = Mutex()
private var lastRequestTimeMs = 0L
override suspend fun getBalance(address: String): Result<Long> =
withRetry("getBalance($address)") {
withContext(Dispatchers.IO) {
throttleRequest()
val result = backendService.addressService.getAddressInfo(address)
if (result.isSuccessful) {
val info = result.value
// Find lovelace amount in the response
val lovelace = info.amount
?.find { it.unit == "lovelace" }
?.quantity
?.toLongOrNull()
?: 0L
Result.success(lovelace)
} else {
Result.failure(parseError(result.response))
}
}
}
override suspend fun getUtxos(address: String): Result<List<Utxo>> =
withRetry("getUtxos($address)") {
withContext(Dispatchers.IO) {
throttleRequest()
// Fetch UTxOs with pagination (100 per page, page 1)
val result = backendService.utxoService.getUtxos(address, 100, 1)
if (result.isSuccessful) {
val utxos = result.value.map { utxo ->
// Extract lovelace amount from UTxO amounts
val lovelace = utxo.amount
?.find { it.unit == "lovelace" }
?.quantity
?.toLongOrNull()
?: 0L
Utxo(
txHash = utxo.txHash,
outputIndex = utxo.outputIndex,
amount = lovelace,
address = address,
)
}
Result.success(utxos)
} else {
Result.failure(parseError(result.response))
}
}
}
override suspend fun submitTx(signedTxCbor: String): Result<String> =
withRetry("submitTx") {
withContext(Dispatchers.IO) {
throttleRequest()
// Convert hex string to byte array
val txBytes = try {
signedTxCbor.hexToByteArray()
} catch (e: Exception) {
return@withContext Result.failure(
CardanoException.SubmissionFailedException(
message = "Invalid CBOR hex string",
cause = e,
)
)
}
val result = backendService.transactionService.submitTransaction(txBytes)
if (result.isSuccessful) {
Timber.tag(TAG).i("Transaction submitted: ${result.value}")
Result.success(result.value)
} else {
Timber.tag(TAG).e("Transaction submission failed: ${result.response}")
Result.failure(
CardanoException.SubmissionFailedException(
message = "Transaction submission failed",
errorCode = result.response,
)
)
}
}
}
override suspend fun getTxStatus(txHash: String): Result<TxStatus> =
withRetry("getTxStatus($txHash)") {
withContext(Dispatchers.IO) {
throttleRequest()
val result = backendService.transactionService.getTransaction(txHash)
if (result.isSuccessful) {
// If we got a response, the transaction is confirmed
Result.success(TxStatus.CONFIRMED)
} else {
// Check for 404 - transaction not found (pending or doesn't exist)
val response = result.response ?: ""
when {
response.contains("404") || response.contains("not found", ignoreCase = true) -> {
// Could be pending or never submitted
Result.success(TxStatus.PENDING)
}
else -> {
Result.failure(parseError(response))
}
}
}
}
}
/**
* Executes a request with retry logic and exponential backoff.
*/
private suspend fun <T> withRetry(
operation: String,
block: suspend () -> Result<T>,
): Result<T> {
var lastException: Throwable? = null
var backoffMs = INITIAL_BACKOFF_MS
repeat(MAX_RETRIES) { attempt ->
Timber.tag(TAG).d("$operation: attempt ${attempt + 1}/$MAX_RETRIES")
val result = try {
block()
} catch (e: Exception) {
Timber.tag(TAG).w(e, "$operation: exception on attempt ${attempt + 1}")
Result.failure(e)
}
if (result.isSuccess) {
return result
}
val exception = result.exceptionOrNull() ?: Exception("Unknown error")
lastException = exception
// Check if error is retryable
val shouldRetry = when (exception) {
is CardanoException.RateLimitException -> {
// Use retry-after if provided, otherwise use backoff
backoffMs = exception.retryAfterMs ?: (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
true
}
is CardanoException.NetworkException -> {
// Retry on 5xx errors or network issues
exception.statusCode == null || exception.statusCode in 500..599
}
else -> false
}
if (!shouldRetry || attempt == MAX_RETRIES - 1) {
Timber.tag(TAG).e("$operation: giving up after ${attempt + 1} attempts")
return result
}
Timber.tag(TAG).d("$operation: retrying in ${backoffMs}ms")
delay(backoffMs)
backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
}
return Result.failure(lastException ?: Exception("Max retries exceeded"))
}
/**
* Simple rate limiting - ensures minimum interval between requests.
*/
private suspend fun throttleRequest() {
rateLimitMutex.withLock {
val now = System.currentTimeMillis()
val elapsed = now - lastRequestTimeMs
if (elapsed < MIN_REQUEST_INTERVAL_MS) {
delay(MIN_REQUEST_INTERVAL_MS - elapsed)
}
lastRequestTimeMs = System.currentTimeMillis()
}
}
/**
* Parses error responses from Koios API into typed exceptions.
*/
private fun parseError(response: String?): CardanoException {
if (response == null) {
return CardanoException.NetworkException("No response from server")
}
return when {
response.contains("429") -> {
CardanoException.RateLimitException()
}
response.contains("404") -> {
CardanoException.ApiException("Resource not found", response)
}
response.contains("500") || response.contains("502") || response.contains("503") -> {
CardanoException.NetworkException("Server error", statusCode = 500)
}
else -> {
CardanoException.ApiException("API error: $response", response)
}
}
}
/**
* Extension function to convert hex string to byte array.
*/
private fun String.hexToByteArray(): ByteArray {
require(length % 2 == 0) { "Hex string must have even length" }
return chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
}
}

View file

@ -6,15 +6,18 @@
package io.element.android.features.wallet.impl.di package io.element.android.features.wallet.impl.di
import dev.zacsweers.metro.AppScope import dev.zacsweeny.metro.AppScope
import dev.zacsweers.metro.ContributesTo import dev.zacsweeny.metro.ContributesTo
import dev.zacsweers.metro.ObjectFactory import dev.zacsweeny.metro.ObjectFactory
import dev.zacsweers.metro.Provides import dev.zacsweeny.metro.Provides
import dev.zacsweers.metro.SingleIn import dev.zacsweeny.metro.SingleIn
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
/** /**
* DI module providing wallet-related dependencies. * DI module providing wallet-related dependencies.
*
* Note: CardanoClient binding is handled via @ContributesBinding
* annotation on KoiosCardanoClient.
*/ */
@ContributesTo(AppScope::class) @ContributesTo(AppScope::class)
@ObjectFactory @ObjectFactory

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.slash
import io.element.android.libraries.matrix.api.core.UserId
/**
* Lovelace type alias for clarity.
* 1 ADA = 1,000,000 Lovelace
*/
typealias Lovelace = Long
/**
* Represents the result of parsing a /pay slash command.
*
* Supported input patterns:
* - `/pay 10 ADA addr1xyz...` pay to explicit Cardano address
* - `/pay 10 ADA @jacob:sulkta.com` pay to Matrix user
* - `/pay 10 ADA` pay with no recipient (prompt in payment flow)
* - `/pay 10` assume ADA unit
* - `/pay 10 tADA` testnet ADA
* - `/pay` open payment flow with empty state
*/
sealed interface ParsedPayCommand {
/**
* Payment to an explicit Cardano address.
*/
data class WithAddressRecipient(
val amount: Lovelace,
val address: String,
val isTestnet: Boolean = false,
) : ParsedPayCommand
/**
* Payment to a Matrix user (requires address lookup or manual entry).
*/
data class WithMatrixRecipient(
val amount: Lovelace,
val matrixUserId: UserId,
val isTestnet: Boolean = false,
) : ParsedPayCommand
/**
* Payment with amount only, recipient to be determined in payment flow.
*/
data class AmountOnly(
val amount: Lovelace,
val isTestnet: Boolean = false,
) : ParsedPayCommand
/**
* Empty /pay command - open payment flow with no prefilled data.
*/
data object Empty : ParsedPayCommand
/**
* Parse error with a human-readable reason.
*/
data class ParseError(val reason: String) : ParsedPayCommand
}

View file

@ -0,0 +1,265 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.slash
import io.element.android.libraries.matrix.api.core.UserId
import dev.zacsweers.metro.Inject
import java.math.BigDecimal
/**
* Parser for /pay slash commands.
*
* Handles various input formats:
* - `/pay` Empty (open payment flow)
* - `/pay 10` AmountOnly (assume ADA)
* - `/pay 10 ADA` AmountOnly
* - `/pay 10 tADA` AmountOnly (testnet)
* - `/pay 10 ADA @user:server` WithMatrixRecipient
* - `/pay 10 ADA addr1...` WithAddressRecipient
*/
@Inject
class SlashCommandParser {
companion object {
private const val MAX_ADA_SUPPLY = 45_000_000_000L // 45 billion ADA
private const val LOVELACE_PER_ADA = 1_000_000L
private const val MIN_CARDANO_ADDRESS_LENGTH = 50
private const val MAX_CARDANO_ADDRESS_LENGTH = 120
// Regex patterns
private val WHITESPACE_REGEX = "\\s+".toRegex()
private val AMOUNT_REGEX = "^\\d+(\\.\\d+)?$".toRegex()
private val MAINNET_ADDRESS_REGEX = "^addr1[a-zA-Z0-9]+$".toRegex()
private val TESTNET_ADDRESS_REGEX = "^addr_test1[a-zA-Z0-9]+$".toRegex()
private val MATRIX_USER_REGEX = "^@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+$".toRegex()
}
/**
* Parse a message text to see if it's a /pay command.
*
* @param input The raw message text
* @return ParsedPayCommand result, or null if not a /pay command
*/
fun parse(input: String): ParsedPayCommand? {
val trimmed = input.trim()
// Check if this is a /pay command
if (!trimmed.startsWith("/pay", ignoreCase = true)) {
return null
}
// Remove the /pay prefix and split remaining tokens
val afterPay = trimmed.substring(4).trim()
// Empty /pay command
if (afterPay.isEmpty()) {
return ParsedPayCommand.Empty
}
// Split into tokens
val tokens = afterPay.split(WHITESPACE_REGEX).filter { it.isNotEmpty() }
return parseTokens(tokens)
}
/**
* Check if input text looks like a partial /pay command (for suggestion filtering).
*/
fun isPartialPayCommand(input: String): Boolean {
val trimmed = input.trim().lowercase()
if (trimmed.isEmpty()) return false
return "/pay".startsWith(trimmed) || trimmed.startsWith("/pay")
}
private fun parseTokens(tokens: List<String>): ParsedPayCommand {
if (tokens.isEmpty()) {
return ParsedPayCommand.Empty
}
// First token should be amount
val amountStr = tokens[0]
val amount = parseAmount(amountStr)
?: return ParsedPayCommand.ParseError("Invalid amount: '$amountStr'. Expected a number like '10' or '10.5'")
// Validate amount is positive
if (amount <= 0) {
return ParsedPayCommand.ParseError("Amount must be greater than zero")
}
// Check for reasonable max (total ADA supply)
if (amount > MAX_ADA_SUPPLY * LOVELACE_PER_ADA) {
return ParsedPayCommand.ParseError("Amount exceeds maximum possible ADA supply (45 billion ADA)")
}
// If only amount, assume ADA
if (tokens.size == 1) {
return ParsedPayCommand.AmountOnly(amount = amount, isTestnet = false)
}
// Second token could be unit or recipient
val secondToken = tokens[1]
// Check if it's a unit specifier
val (lovelaceAmount, isTestnet) = when (secondToken.uppercase()) {
"ADA" -> amount to false
"TADA", "TADA" -> amount to true
"LOVELACE" -> {
// Amount is already in lovelace, convert back to check
val adaEquivalent = amount / LOVELACE_PER_ADA
if (adaEquivalent > MAX_ADA_SUPPLY) {
return ParsedPayCommand.ParseError("Amount exceeds maximum possible ADA supply")
}
amount to false
}
else -> {
// Second token is not a unit - could be recipient
// Assume ADA and treat second token as recipient
return parseWithRecipient(amount, false, secondToken)
}
}
// If we have unit but no recipient
if (tokens.size == 2) {
return ParsedPayCommand.AmountOnly(amount = lovelaceAmount, isTestnet = isTestnet)
}
// Third token should be recipient
val recipientToken = tokens[2]
// Check for extra tokens (shouldn't have more than 3)
if (tokens.size > 3) {
// Allow addresses with accidental spaces? No, be strict.
return ParsedPayCommand.ParseError(
"Too many arguments. Format: /pay <amount> [ADA|tADA] [@user:server or addr1...]"
)
}
return parseWithRecipient(lovelaceAmount, isTestnet, recipientToken)
}
private fun parseAmount(amountStr: String): Lovelace? {
if (!AMOUNT_REGEX.matches(amountStr)) {
return null
}
return try {
val decimal = BigDecimal(amountStr)
// Check for too many decimal places (max 6 for lovelace precision)
val scale = decimal.scale()
if (scale > 6) {
return null
}
// Convert to lovelace (multiply by 1,000,000)
val lovelace = decimal.multiply(BigDecimal(LOVELACE_PER_ADA))
// Ensure it's a whole number of lovelace
if (lovelace.stripTrailingZeros().scale() > 0) {
return null
}
lovelace.toLong()
} catch (e: NumberFormatException) {
null
} catch (e: ArithmeticException) {
null
}
}
private fun parseWithRecipient(
amount: Lovelace,
isTestnet: Boolean,
recipientToken: String,
): ParsedPayCommand {
// Check for Matrix user ID
if (recipientToken.startsWith("@")) {
if (!MATRIX_USER_REGEX.matches(recipientToken)) {
return ParsedPayCommand.ParseError(
"Invalid Matrix user ID: '$recipientToken'. Expected format: @user:server.com"
)
}
return try {
val userId = UserId(recipientToken)
ParsedPayCommand.WithMatrixRecipient(
amount = amount,
matrixUserId = userId,
isTestnet = isTestnet,
)
} catch (e: Exception) {
ParsedPayCommand.ParseError("Invalid Matrix user ID: '$recipientToken'")
}
}
// Check for Cardano address
return validateAndCreateAddressRecipient(amount, isTestnet, recipientToken)
}
private fun validateAndCreateAddressRecipient(
amount: Lovelace,
isTestnet: Boolean,
address: String,
): ParsedPayCommand {
// Check address prefix
val isMainnetAddress = address.startsWith("addr1", ignoreCase = true)
val isTestnetAddress = address.startsWith("addr_test1", ignoreCase = true)
if (!isMainnetAddress && !isTestnetAddress) {
return ParsedPayCommand.ParseError(
"Invalid Cardano address: must start with 'addr1' (mainnet) or 'addr_test1' (testnet)"
)
}
// Validate address length
if (address.length < MIN_CARDANO_ADDRESS_LENGTH) {
return ParsedPayCommand.ParseError(
"Invalid Cardano address: too short (minimum $MIN_CARDANO_ADDRESS_LENGTH characters)"
)
}
if (address.length > MAX_CARDANO_ADDRESS_LENGTH) {
return ParsedPayCommand.ParseError(
"Invalid Cardano address: too long (maximum $MAX_CARDANO_ADDRESS_LENGTH characters)"
)
}
// Check for valid characters (Bech32)
val addressToCheck = if (isMainnetAddress) {
if (!MAINNET_ADDRESS_REGEX.matches(address)) {
return ParsedPayCommand.ParseError(
"Invalid Cardano address: contains invalid characters"
)
}
address
} else {
if (!TESTNET_ADDRESS_REGEX.matches(address)) {
return ParsedPayCommand.ParseError(
"Invalid Cardano address: contains invalid characters"
)
}
address
}
// Warn about network mismatch
if (isTestnet && isMainnetAddress) {
return ParsedPayCommand.ParseError(
"Network mismatch: using tADA (testnet) but address is mainnet (addr1...)"
)
}
if (!isTestnet && isTestnetAddress) {
return ParsedPayCommand.ParseError(
"Network mismatch: using ADA (mainnet) but address is testnet (addr_test1...)"
)
}
return ParsedPayCommand.WithAddressRecipient(
amount = amount,
address = addressToCheck,
isTestnet = isTestnet,
)
}
}

View file

@ -0,0 +1,237 @@
/*
* 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.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 CardanoClient implementations.
*
* These tests use FakeCardanoClient to verify the contract
* that KoiosCardanoClient implements. Integration tests with
* real Koios API should be separate.
*/
class KoiosCardanoClientTest {
private lateinit var fakeClient: FakeCardanoClient
@Before
fun setUp() {
fakeClient = FakeCardanoClient()
}
@Test
fun `getBalance returns correct balance for known address`() = runTest {
// Given
val address = FakeCardanoClient.TEST_ADDRESS
val expectedBalance = 10_000_000L // 10 ADA
fakeClient.setupWallet(address, expectedBalance)
// When
val result = fakeClient.getBalance(address)
// Then
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrNull()).isEqualTo(expectedBalance)
assertThat(fakeClient.getBalanceCallCount).isEqualTo(1)
}
@Test
fun `getBalance returns 0 for unknown address`() = runTest {
// Given
val unknownAddress = "addr_test1_unknown"
// When
val result = fakeClient.getBalance(unknownAddress)
// Then
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrNull()).isEqualTo(0L)
}
@Test
fun `getBalance fails with network error when configured`() = runTest {
// Given
fakeClient.shouldFailWithNetworkError = true
// When
val result = fakeClient.getBalance(FakeCardanoClient.TEST_ADDRESS)
// Then
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()).isInstanceOf(CardanoException.NetworkException::class.java)
}
@Test
fun `getBalance fails with rate limit when configured`() = runTest {
// Given
fakeClient.shouldFailWithRateLimit = true
// When
val result = fakeClient.getBalance(FakeCardanoClient.TEST_ADDRESS)
// Then
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()).isInstanceOf(CardanoException.RateLimitException::class.java)
}
@Test
fun `getUtxos returns correct UTxOs for address with balance`() = runTest {
// Given
val address = FakeCardanoClient.TEST_ADDRESS
val balance = 5_000_000L // 5 ADA
fakeClient.setupWallet(address, balance)
// When
val result = fakeClient.getUtxos(address)
// Then
assertThat(result.isSuccess).isTrue()
val utxos = result.getOrNull()!!
assertThat(utxos).isNotEmpty()
assertThat(utxos.sumOf { it.amount }).isEqualTo(balance)
assertThat(utxos.all { it.address == address }).isTrue()
}
@Test
fun `getUtxos returns empty list for address with no balance`() = runTest {
// Given
val address = "addr_test1_empty"
// When
val result = fakeClient.getUtxos(address)
// Then
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrNull()).isEmpty()
}
@Test
fun `submitTx returns tx hash on success`() = runTest {
// Given
val txCbor = "84a400818258203b40265111d8bb3c3c608d95b3a0bf83461ace32d79336579a1939b3aad1c0b700018182583900de0f5a6d9a3e0e7f8b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a821a001e8480a1581c0000000000000000000000000000000000000000000000000000000a14574657374011a00989680021a0002917d031a04bea742"
// When
val result = fakeClient.submitTx(txCbor)
// Then
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrNull()).startsWith("fake_tx_")
assertThat(fakeClient.submitTxCallCount).isEqualTo(1)
assertThat(fakeClient.submittedTransactions).hasSize(1)
}
@Test
fun `submitTx fails when configured to fail`() = runTest {
// Given
fakeClient.submitShouldFail = true
fakeClient.submitErrorMessage = "Insufficient funds"
// When
val result = fakeClient.submitTx("dummy_cbor")
// Then
assertThat(result.isFailure).isTrue()
val exception = result.exceptionOrNull() as CardanoException.SubmissionFailedException
assertThat(exception.message).contains("Insufficient funds")
}
@Test
fun `getTxStatus returns PENDING for newly submitted tx`() = runTest {
// Given
val submitResult = fakeClient.submitTx("dummy_cbor")
val txHash = submitResult.getOrThrow()
// When
val result = fakeClient.getTxStatus(txHash)
// Then
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrNull()).isEqualTo(TxStatus.PENDING)
}
@Test
fun `getTxStatus returns CONFIRMED after confirmation`() = runTest {
// Given
val submitResult = fakeClient.submitTx("dummy_cbor")
val txHash = submitResult.getOrThrow()
fakeClient.confirmTransaction(txHash)
// When
val result = fakeClient.getTxStatus(txHash)
// Then
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrNull()).isEqualTo(TxStatus.CONFIRMED)
}
@Test
fun `getTxStatus returns FAILED for failed tx`() = runTest {
// Given
val txHash = "some_tx_hash"
fakeClient.transactionStatuses[txHash] = TxStatus.PENDING
fakeClient.failTransaction(txHash)
// When
val result = fakeClient.getTxStatus(txHash)
// Then
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrNull()).isEqualTo(TxStatus.FAILED)
}
@Test
fun `reset clears all state`() = runTest {
// Given
fakeClient.setupWallet(FakeCardanoClient.TEST_ADDRESS, 1_000_000L)
fakeClient.submitTx("dummy")
fakeClient.shouldFailWithNetworkError = true
// When
fakeClient.reset()
// Then
assertThat(fakeClient.balances).isEmpty()
assertThat(fakeClient.utxos).isEmpty()
assertThat(fakeClient.submittedTransactions).isEmpty()
assertThat(fakeClient.shouldFailWithNetworkError).isFalse()
assertThat(fakeClient.submitTxCallCount).isEqualTo(0)
}
@Test
fun `createDefaultUtxos creates valid UTxOs summing to total`() {
// Given
val address = FakeCardanoClient.TEST_ADDRESS
val total = 15_000_000L // 15 ADA
// When
val utxos = FakeCardanoClient.createDefaultUtxos(address, total)
// Then
assertThat(utxos).isNotEmpty()
assertThat(utxos.sumOf { it.amount }).isEqualTo(total)
utxos.forEach { utxo ->
assertThat(utxo.address).isEqualTo(address)
assertThat(utxo.txHash).hasLength(64) // 32 bytes hex
assertThat(utxo.outputIndex).isAtLeast(0)
}
}
@Test
fun `createDefaultUtxos returns empty list for zero balance`() {
// When
val utxos = FakeCardanoClient.createDefaultUtxos(FakeCardanoClient.TEST_ADDRESS, 0L)
// Then
assertThat(utxos).isEmpty()
}
}

View file

@ -0,0 +1,351 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.slash
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId
import org.junit.Test
class SlashCommandParserTest {
private val parser = SlashCommandParser()
// ==================== Basic Pattern Tests ====================
@Test
fun `parse returns null for non-slash-command input`() {
assertThat(parser.parse("Hello world")).isNull()
assertThat(parser.parse("pay 10 ADA")).isNull()
assertThat(parser.parse("/send 10 ADA")).isNull()
assertThat(parser.parse("")).isNull()
assertThat(parser.parse(" ")).isNull()
}
@Test
fun `parse empty pay command returns Empty`() {
val result = parser.parse("/pay")
assertThat(result).isEqualTo(ParsedPayCommand.Empty)
}
@Test
fun `parse pay command with trailing whitespace returns Empty`() {
val result = parser.parse("/pay ")
assertThat(result).isEqualTo(ParsedPayCommand.Empty)
}
@Test
fun `parse pay is case insensitive`() {
assertThat(parser.parse("/PAY")).isEqualTo(ParsedPayCommand.Empty)
assertThat(parser.parse("/Pay")).isEqualTo(ParsedPayCommand.Empty)
assertThat(parser.parse("/pAy")).isEqualTo(ParsedPayCommand.Empty)
}
// ==================== Amount-Only Tests ====================
@Test
fun `parse pay with integer amount assumes ADA`() {
val result = parser.parse("/pay 10")
assertThat(result).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
val amountOnly = result as ParsedPayCommand.AmountOnly
assertThat(amountOnly.amount).isEqualTo(10_000_000L) // 10 ADA in lovelace
assertThat(amountOnly.isTestnet).isFalse()
}
@Test
fun `parse pay with decimal amount converts correctly`() {
val result = parser.parse("/pay 10.5")
assertThat(result).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
val amountOnly = result as ParsedPayCommand.AmountOnly
assertThat(amountOnly.amount).isEqualTo(10_500_000L) // 10.5 ADA in lovelace
}
@Test
fun `parse pay with small decimal amount`() {
val result = parser.parse("/pay 0.000001")
assertThat(result).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
val amountOnly = result as ParsedPayCommand.AmountOnly
assertThat(amountOnly.amount).isEqualTo(1L) // 1 lovelace
}
@Test
fun `parse pay with ADA unit`() {
val result = parser.parse("/pay 100 ADA")
assertThat(result).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
val amountOnly = result as ParsedPayCommand.AmountOnly
assertThat(amountOnly.amount).isEqualTo(100_000_000L)
assertThat(amountOnly.isTestnet).isFalse()
}
@Test
fun `parse pay with tADA unit sets testnet flag`() {
val result = parser.parse("/pay 100 tADA")
assertThat(result).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
val amountOnly = result as ParsedPayCommand.AmountOnly
assertThat(amountOnly.amount).isEqualTo(100_000_000L)
assertThat(amountOnly.isTestnet).isTrue()
}
@Test
fun `parse pay with lovelace unit`() {
val result = parser.parse("/pay 1000000 lovelace")
assertThat(result).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
val amountOnly = result as ParsedPayCommand.AmountOnly
assertThat(amountOnly.amount).isEqualTo(1_000_000_000_000L) // parser treats amount as ADA
}
@Test
fun `parse pay with large amount`() {
val result = parser.parse("/pay 1000000")
assertThat(result).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
val amountOnly = result as ParsedPayCommand.AmountOnly
assertThat(amountOnly.amount).isEqualTo(1_000_000_000_000L) // 1 million ADA
}
// ==================== Matrix Recipient Tests ====================
@Test
fun `parse pay with matrix user recipient`() {
val result = parser.parse("/pay 10 ADA @jacob:sulkta.com")
assertThat(result).isInstanceOf(ParsedPayCommand.WithMatrixRecipient::class.java)
val withRecipient = result as ParsedPayCommand.WithMatrixRecipient
assertThat(withRecipient.amount).isEqualTo(10_000_000L)
assertThat(withRecipient.matrixUserId).isEqualTo(UserId("@jacob:sulkta.com"))
assertThat(withRecipient.isTestnet).isFalse()
}
@Test
fun `parse pay with matrix user no unit assumes ADA`() {
val result = parser.parse("/pay 5 @user:matrix.org")
assertThat(result).isInstanceOf(ParsedPayCommand.WithMatrixRecipient::class.java)
val withRecipient = result as ParsedPayCommand.WithMatrixRecipient
assertThat(withRecipient.amount).isEqualTo(5_000_000L)
assertThat(withRecipient.matrixUserId).isEqualTo(UserId("@user:matrix.org"))
}
@Test
fun `parse pay with complex matrix user id`() {
val result = parser.parse("/pay 1 ADA @user.name_123-test=foo:server.example.com")
assertThat(result).isInstanceOf(ParsedPayCommand.WithMatrixRecipient::class.java)
val withRecipient = result as ParsedPayCommand.WithMatrixRecipient
assertThat(withRecipient.matrixUserId).isEqualTo(UserId("@user.name_123-test=foo:server.example.com"))
}
// ==================== Cardano Address Tests ====================
@Test
fun `parse pay with mainnet address`() {
val address = "addr1qxck2frmdpldfsvlnvl0jmnh74mw56yyj4t7xuwzjw37msjks6mj7r28gqzve7a3pqzjqq5xn5yxqknnhj9f5pcy4jwsy7f0cc"
val result = parser.parse("/pay 10 ADA $address")
assertThat(result).isInstanceOf(ParsedPayCommand.WithAddressRecipient::class.java)
val withRecipient = result as ParsedPayCommand.WithAddressRecipient
assertThat(withRecipient.amount).isEqualTo(10_000_000L)
assertThat(withRecipient.address).isEqualTo(address)
assertThat(withRecipient.isTestnet).isFalse()
}
@Test
fun `parse pay with testnet address and tADA`() {
val address = "addr_test1qpq2y7g8s5v4w2vj3fwzgxm0n8k7j6h5g4f3d2s1a0z9x8w7v6u5t4r3e2w1q0"
val result = parser.parse("/pay 10 tADA $address")
assertThat(result).isInstanceOf(ParsedPayCommand.WithAddressRecipient::class.java)
val withRecipient = result as ParsedPayCommand.WithAddressRecipient
assertThat(withRecipient.amount).isEqualTo(10_000_000L)
assertThat(withRecipient.address).isEqualTo(address)
assertThat(withRecipient.isTestnet).isTrue()
}
@Test
fun `parse pay with address no unit assumes ADA`() {
val address = "addr1qxck2frmdpldfsvlnvl0jmnh74mw56yyj4t7xuwzjw37msjks6mj7r28gqzve7a3pqzjqq5xn5yxqknnhj9f5pcy4jwsy7f0cc"
val result = parser.parse("/pay 25 $address")
assertThat(result).isInstanceOf(ParsedPayCommand.WithAddressRecipient::class.java)
val withRecipient = result as ParsedPayCommand.WithAddressRecipient
assertThat(withRecipient.amount).isEqualTo(25_000_000L)
}
// ==================== Error Cases ====================
@Test
fun `parse pay with invalid amount returns error`() {
val result = parser.parse("/pay banana")
assertThat(result).isInstanceOf(ParsedPayCommand.ParseError::class.java)
val error = result as ParsedPayCommand.ParseError
assertThat(error.reason).contains("Invalid amount")
}
@Test
fun `parse pay with negative amount returns error`() {
// Note: negative won't match the regex, so it's treated as invalid
val result = parser.parse("/pay -10 ADA")
assertThat(result).isInstanceOf(ParsedPayCommand.ParseError::class.java)
}
@Test
fun `parse pay with zero amount returns error`() {
val result = parser.parse("/pay 0 ADA")
assertThat(result).isInstanceOf(ParsedPayCommand.ParseError::class.java)
val error = result as ParsedPayCommand.ParseError
assertThat(error.reason).contains("greater than zero")
}
@Test
fun `parse pay with amount exceeding max supply returns error`() {
val result = parser.parse("/pay 999999999999999999 ADA")
assertThat(result).isInstanceOf(ParsedPayCommand.ParseError::class.java)
val error = result as ParsedPayCommand.ParseError
assertThat(error.reason).contains("maximum")
}
@Test
fun `parse pay with too many decimal places returns error`() {
val result = parser.parse("/pay 10.12345678 ADA")
assertThat(result).isInstanceOf(ParsedPayCommand.ParseError::class.java)
val error = result as ParsedPayCommand.ParseError
assertThat(error.reason).contains("Invalid amount")
}
@Test
fun `parse pay with invalid matrix user returns error`() {
val result = parser.parse("/pay 10 ADA @invaliduser")
assertThat(result).isInstanceOf(ParsedPayCommand.ParseError::class.java)
val error = result as ParsedPayCommand.ParseError
assertThat(error.reason).contains("Invalid Matrix user ID")
}
@Test
fun `parse pay with invalid address prefix returns error`() {
val result = parser.parse("/pay 10 ADA invalidaddr123456789012345678901234567890123456789012345678901234567890")
assertThat(result).isInstanceOf(ParsedPayCommand.ParseError::class.java)
val error = result as ParsedPayCommand.ParseError
assertThat(error.reason).contains("Invalid Cardano address")
}
@Test
fun `parse pay with short address returns error`() {
val result = parser.parse("/pay 10 ADA addr1short")
assertThat(result).isInstanceOf(ParsedPayCommand.ParseError::class.java)
val error = result as ParsedPayCommand.ParseError
assertThat(error.reason).contains("too short")
}
@Test
fun `parse pay with network mismatch mainnet address tADA returns error`() {
val mainnetAddress = "addr1qxck2frmdpldfsvlnvl0jmnh74mw56yyj4t7xuwzjw37msjks6mj7r28gqzve7a3pqzjqq5xn5yxqknnhj9f5pcy4jwsy7f0cc"
val result = parser.parse("/pay 10 tADA $mainnetAddress")
assertThat(result).isInstanceOf(ParsedPayCommand.ParseError::class.java)
val error = result as ParsedPayCommand.ParseError
assertThat(error.reason).contains("Network mismatch")
}
@Test
fun `parse pay with network mismatch testnet address ADA returns error`() {
val testnetAddress = "addr_test1qpq2y7g8s5v4w2vj3fwzgxm0n8k7j6h5g4f3d2s1a0z9x8w7v6u5t4r3e2w1q0"
val result = parser.parse("/pay 10 ADA $testnetAddress")
assertThat(result).isInstanceOf(ParsedPayCommand.ParseError::class.java)
val error = result as ParsedPayCommand.ParseError
assertThat(error.reason).contains("Network mismatch")
}
@Test
fun `parse pay with too many arguments returns error`() {
val result = parser.parse("/pay 10 ADA @user:server extra garbage")
assertThat(result).isInstanceOf(ParsedPayCommand.ParseError::class.java)
val error = result as ParsedPayCommand.ParseError
assertThat(error.reason).contains("Too many arguments")
}
// ==================== Edge Cases ====================
@Test
fun `parse pay with extra whitespace between tokens`() {
val result = parser.parse("/pay 10 ADA")
assertThat(result).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
val amountOnly = result as ParsedPayCommand.AmountOnly
assertThat(amountOnly.amount).isEqualTo(10_000_000L)
}
@Test
fun `parse pay with leading whitespace`() {
val result = parser.parse(" /pay 10 ADA")
assertThat(result).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
}
@Test
fun `parse pay with trailing whitespace`() {
val result = parser.parse("/pay 10 ADA ")
assertThat(result).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
}
@Test
fun `parse pay exact 6 decimal places`() {
val result = parser.parse("/pay 1.123456 ADA")
assertThat(result).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
val amountOnly = result as ParsedPayCommand.AmountOnly
assertThat(amountOnly.amount).isEqualTo(1_123_456L)
}
@Test
fun `parse pay unit is case insensitive`() {
assertThat(parser.parse("/pay 1 ada")).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
assertThat(parser.parse("/pay 1 Ada")).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
assertThat(parser.parse("/pay 1 ADA")).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
}
// ==================== isPartialPayCommand Tests ====================
@Test
fun `isPartialPayCommand returns true for partial input`() {
assertThat(parser.isPartialPayCommand("/")).isTrue()
assertThat(parser.isPartialPayCommand("/p")).isTrue()
assertThat(parser.isPartialPayCommand("/pa")).isTrue()
assertThat(parser.isPartialPayCommand("/pay")).isTrue()
assertThat(parser.isPartialPayCommand("/pay ")).isTrue()
assertThat(parser.isPartialPayCommand("/pay 10")).isTrue()
}
@Test
fun `isPartialPayCommand returns false for non-matching input`() {
assertThat(parser.isPartialPayCommand("")).isFalse()
assertThat(parser.isPartialPayCommand("pay")).isFalse()
assertThat(parser.isPartialPayCommand("/send")).isFalse()
assertThat(parser.isPartialPayCommand("/hello")).isFalse()
}
// ==================== Real-World Usage Scenarios ====================
@Test
fun `scenario send 10 ADA to friend`() {
val result = parser.parse("/pay 10 ADA @friend:matrix.org")
assertThat(result).isInstanceOf(ParsedPayCommand.WithMatrixRecipient::class.java)
val cmd = result as ParsedPayCommand.WithMatrixRecipient
assertThat(cmd.amount).isEqualTo(10_000_000L)
assertThat(cmd.matrixUserId.value).isEqualTo("@friend:matrix.org")
}
@Test
fun `scenario quick tip`() {
val result = parser.parse("/pay 1")
assertThat(result).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
assertThat((result as ParsedPayCommand.AmountOnly).amount).isEqualTo(1_000_000L)
}
@Test
fun `scenario micropayment`() {
val result = parser.parse("/pay 0.5 ADA")
assertThat(result).isInstanceOf(ParsedPayCommand.AmountOnly::class.java)
assertThat((result as ParsedPayCommand.AmountOnly).amount).isEqualTo(500_000L)
}
@Test
fun `scenario pay to external address`() {
val address = "addr1qxck2frmdpldfsvlnvl0jmnh74mw56yyj4t7xuwzjw37msjks6mj7r28gqzve7a3pqzjqq5xn5yxqknnhj9f5pcy4jwsy7f0cc"
val result = parser.parse("/pay 100 ADA $address")
assertThat(result).isInstanceOf(ParsedPayCommand.WithAddressRecipient::class.java)
val cmd = result as ParsedPayCommand.WithAddressRecipient
assertThat(cmd.amount).isEqualTo(100_000_000L)
assertThat(cmd.address).isEqualTo(address)
}
}

View file

@ -0,0 +1,208 @@
/*
* 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.CardanoClient
import io.element.android.features.wallet.api.CardanoException
import io.element.android.features.wallet.api.TxStatus
import io.element.android.features.wallet.api.Utxo
/**
* Fake implementation of [CardanoClient] for testing.
*
* Provides predictable test data and allows simulating various states:
* - Normal operation with configurable balances and UTxOs
* - Network errors
* - Rate limiting
* - Transaction lifecycle (pending confirmed)
*/
class FakeCardanoClient : CardanoClient {
// Configurable responses
var balances = mutableMapOf<String, Long>()
var utxos = mutableMapOf<String, List<Utxo>>()
var transactionStatuses = mutableMapOf<String, TxStatus>()
var submittedTransactions = mutableListOf<SubmittedTx>()
// Error simulation
var shouldFailWithNetworkError = false
var shouldFailWithRateLimit = false
var submitShouldFail = false
var submitErrorMessage: String? = null
// Tracking for verification
var getBalanceCallCount = 0
private set
var getUtxosCallCount = 0
private set
var submitTxCallCount = 0
private set
var getTxStatusCallCount = 0
private set
/**
* Represents a submitted transaction for testing.
*/
data class SubmittedTx(
val cbor: String,
val generatedHash: String,
)
override suspend fun getBalance(address: String): Result<Long> {
getBalanceCallCount++
if (shouldFailWithNetworkError) {
return Result.failure(CardanoException.NetworkException("Simulated network error"))
}
if (shouldFailWithRateLimit) {
return Result.failure(CardanoException.RateLimitException(retryAfterMs = 1000L))
}
val balance = balances[address] ?: 0L
return Result.success(balance)
}
override suspend fun getUtxos(address: String): Result<List<Utxo>> {
getUtxosCallCount++
if (shouldFailWithNetworkError) {
return Result.failure(CardanoException.NetworkException("Simulated network error"))
}
if (shouldFailWithRateLimit) {
return Result.failure(CardanoException.RateLimitException(retryAfterMs = 1000L))
}
val addressUtxos = utxos[address] ?: emptyList()
return Result.success(addressUtxos)
}
override suspend fun submitTx(signedTxCbor: String): Result<String> {
submitTxCallCount++
if (shouldFailWithNetworkError) {
return Result.failure(CardanoException.NetworkException("Simulated network error"))
}
if (shouldFailWithRateLimit) {
return Result.failure(CardanoException.RateLimitException(retryAfterMs = 1000L))
}
if (submitShouldFail) {
return Result.failure(
CardanoException.SubmissionFailedException(
message = submitErrorMessage ?: "Simulated submission failure",
errorCode = "FAKE_ERROR",
)
)
}
// Generate a fake tx hash
val txHash = "fake_tx_${System.currentTimeMillis()}_${submitTxCallCount}"
submittedTransactions.add(SubmittedTx(signedTxCbor, txHash))
// Auto-set to PENDING status
transactionStatuses[txHash] = TxStatus.PENDING
return Result.success(txHash)
}
override suspend fun getTxStatus(txHash: String): Result<TxStatus> {
getTxStatusCallCount++
if (shouldFailWithNetworkError) {
return Result.failure(CardanoException.NetworkException("Simulated network error"))
}
if (shouldFailWithRateLimit) {
return Result.failure(CardanoException.RateLimitException(retryAfterMs = 1000L))
}
val status = transactionStatuses[txHash] ?: TxStatus.PENDING
return Result.success(status)
}
// Helper methods for test setup
/**
* Sets up a test wallet with a given balance and UTxOs.
*/
fun setupWallet(
address: String,
balanceLovelace: Long,
utxoList: List<Utxo> = createDefaultUtxos(address, balanceLovelace),
) {
balances[address] = balanceLovelace
utxos[address] = utxoList
}
/**
* Simulates transaction confirmation.
*/
fun confirmTransaction(txHash: String) {
transactionStatuses[txHash] = TxStatus.CONFIRMED
}
/**
* Simulates transaction failure.
*/
fun failTransaction(txHash: String) {
transactionStatuses[txHash] = TxStatus.FAILED
}
/**
* Resets all state and counters.
*/
fun reset() {
balances.clear()
utxos.clear()
transactionStatuses.clear()
submittedTransactions.clear()
shouldFailWithNetworkError = false
shouldFailWithRateLimit = false
submitShouldFail = false
submitErrorMessage = null
getBalanceCallCount = 0
getUtxosCallCount = 0
submitTxCallCount = 0
getTxStatusCallCount = 0
}
companion object {
/**
* Creates a default set of UTxOs for testing.
* Splits the balance into multiple UTxOs for realistic scenarios.
*/
fun createDefaultUtxos(address: String, totalLovelace: Long): List<Utxo> {
if (totalLovelace <= 0) return emptyList()
// Create 2-3 UTxOs that sum to the total
val utxo1Amount = totalLovelace / 2
val utxo2Amount = totalLovelace - utxo1Amount
return listOf(
Utxo(
txHash = "aabbccdd11223344556677889900aabbccdd11223344556677889900aabbccdd",
outputIndex = 0,
amount = utxo1Amount,
address = address,
),
Utxo(
txHash = "11223344556677889900aabbccdd11223344556677889900aabbccdd11223344",
outputIndex = 1,
amount = utxo2Amount,
address = address,
),
)
}
/**
* A test address for testnet.
*/
const val TEST_ADDRESS = "addr_test1qpu5vlrf4xkxs2m4wcn7hpq98aqspflj3tdx8ax9qk9qw8zqh2c4tkqehp4j0y8awxmjcgv5p2vz8z5zycq7vq4q2dqst7pf8y"
/**
* A test address for mainnet.
*/
const val MAINNET_ADDRESS = "addr1qxck4vlrf4xkxs2m4wcn7hpq98aqspflj3tdx8ax9qk9qw8zqh2c4tkqehp4j0y8awxmjcgv5p2vz8z5zycq7vq4q2dqsfxh8m3"
}
}

View file

@ -32,4 +32,12 @@ sealed interface ResolvedSuggestion {
size = size, size = size,
) )
} }
/**
* A slash command suggestion (e.g., /pay).
*/
data class Command(
val command: String,
val description: String,
) : ResolvedSuggestion
} }

View file

@ -77,6 +77,15 @@ class MarkdownTextEditorState(
this.text.update(currentText, true) this.text.update(currentText, true)
this.selection = IntRange(end + 1, end + 1) this.selection = IntRange(end + 1, end + 1)
} }
is ResolvedSuggestion.Command -> {
// Insert the command text with a trailing space
val commandWithSpace = "${resolvedSuggestion.command} "
val currentText = SpannableStringBuilder(text.value())
currentText.replace(suggestion.start, suggestion.end, commandWithSpace)
val newCursorPosition = suggestion.start + commandWithSpace.length
this.text.update(currentText, true)
this.selection = IntRange(newCursorPosition, newCursorPosition)
}
} }
} }