diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/CardanoClient.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/CardanoClient.kt index a74f15a377..0b291cddde 100644 --- a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/CardanoClient.kt +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/CardanoClient.kt @@ -70,4 +70,15 @@ interface CardanoClient { * @return List of [TxSummary] objects, most recent first */ suspend fun getAddressTransactions(address: String, limit: Int = 20): Result> + + /** + * Resolve an ADA Handle to a Cardano address. + * + * ADA Handles are human-readable names (e.g., $cobb) that resolve to Cardano addresses. + * Handle resolution is case-insensitive. + * + * @param handle Handle name WITHOUT the $ prefix (e.g., "cobb" not "$cobb") + * @return Bech32 Cardano address if handle exists, null if not found + */ + suspend fun resolveHandle(handle: String): Result } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt index 2f9e88889b..18f971b2e0 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt @@ -45,6 +45,10 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient { private const val MAX_BACKOFF_MS = 10000L private const val MIN_REQUEST_INTERVAL_MS = 100L private val JSON_MEDIA_TYPE = "application/json".toMediaType() + + // ADA Handle policy ID (same for mainnet and testnet) + private const val ADA_HANDLE_POLICY_ID = "f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a" + private const val HANDLE_CACHE_TTL_MS = 60 * 60 * 1000L // 1 hour } private val httpClient: OkHttpClient by lazy { @@ -64,6 +68,10 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient { private val rateLimitMutex = Mutex() private var lastRequestTimeMs = 0L + // Handle resolution cache + private data class CachedHandle(val address: String?, val timestamp: Long) + private val handleCache = mutableMapOf() + override suspend fun getBalance(address: String): Result = withRetry("getBalance($address)") { withContext(Dispatchers.IO) { @@ -334,6 +342,63 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient { } } + override suspend fun resolveHandle(handle: String): Result = + withRetry("resolveHandle($handle)") { + withContext(Dispatchers.IO) { + // Normalize handle to lowercase + val normalizedHandle = handle.lowercase().trim() + + // Check cache first + val cached = handleCache[normalizedHandle] + if (cached != null && System.currentTimeMillis() - cached.timestamp < HANDLE_CACHE_TTL_MS) { + Timber.tag(TAG).d("resolveHandle: cache hit for $normalizedHandle -> ${cached.address}") + return@withContext Result.success(cached.address) + } + + throttleRequest() + + // Convert handle to hex (ASCII bytes to hex string) + val handleHex = normalizedHandle.toByteArray(Charsets.US_ASCII) + .joinToString("") { "%02x".format(it) } + + val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}asset_addresses" + val body = JSONObject().apply { + put("_asset_policy", ADA_HANDLE_POLICY_ID) + put("_asset_name", handleHex) + }.toString() + + Timber.tag(TAG).d("resolveHandle: $normalizedHandle -> hex=$handleHex, url=$url") + + val request = Request.Builder() + .url(url) + .post(body.toRequestBody(JSON_MEDIA_TYPE)) + .header("Accept", "application/json") + .build() + + val response = httpClient.newCall(request).execute() + val responseBody = response.body?.string() ?: "" + + Timber.tag(TAG).d("resolveHandle response: code=${response.code}, body=${responseBody.take(500)}") + + if (!response.isSuccessful) { + return@withContext Result.failure(parseHttpError(response.code, responseBody)) + } + + val jsonArray = JSONArray(responseBody) + val address = if (jsonArray.length() > 0) { + jsonArray.getJSONObject(0).getString("payment_address") + } else { + null + } + + // Cache the result + handleCache[normalizedHandle] = CachedHandle(address, System.currentTimeMillis()) + + Timber.tag(TAG).d("resolveHandle: $normalizedHandle -> $address") + Result.success(address) + } + } + private suspend fun withRetry( operation: String, block: suspend () -> Result, diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryPresenter.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryPresenter.kt index f61f7ed474..465651643c 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryPresenter.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryPresenter.kt @@ -54,6 +54,8 @@ class PaymentEntryPresenter @AssistedInject constructor( private const val MAX_ADA_SUPPLY = 45_000_000_000L private val CARDANO_ADDRESS_REGEX = "^addr(_test)?1[a-zA-Z0-9]+$".toRegex() private val MATRIX_USER_REGEX = "^@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+$".toRegex() + // ADA Handle: $handle format with alphanumeric, underscore, dash, period + private val HANDLE_REGEX = "^\\\$[a-zA-Z0-9_.-]+$".toRegex() } @Composable @@ -124,10 +126,11 @@ class PaymentEntryPresenter @AssistedInject constructor( } } - // Look up Cardano address when a Matrix user is entered + // Look up Cardano address when a Matrix user or ADA Handle is entered LaunchedEffect(recipientInput) { val isMatrixUser = MATRIX_USER_REGEX.matches(recipientInput) val isCardanoAddress = CARDANO_ADDRESS_REGEX.matches(recipientInput) + val isHandle = HANDLE_REGEX.matches(recipientInput) when { recipientInput.isBlank() -> { @@ -140,6 +143,37 @@ class PaymentEntryPresenter @AssistedInject constructor( // Clear manual entry when direct address is entered manualAddressInput = "" } + isHandle -> { + // ADA Handle resolution + val handleName = recipientInput.removePrefix("$") + recipientResolutionState = RecipientResolutionState.Resolving(recipientInput) + resolvedCardanoAddress = null + + Timber.tag(TAG).d("Resolving ADA Handle: $recipientInput...") + + cardanoClient.resolveHandle(handleName) + .onSuccess { address -> + if (address != null) { + Timber.tag(TAG).i("Resolved $recipientInput -> $address") + recipientResolutionState = RecipientResolutionState.HandleResolved( + handle = recipientInput, + address = address + ) + resolvedCardanoAddress = address + } else { + Timber.tag(TAG).d("Handle $recipientInput not found") + recipientResolutionState = RecipientResolutionState.Error( + "Handle $recipientInput not found" + ) + } + } + .onFailure { e -> + Timber.tag(TAG).w(e, "Failed to resolve handle $recipientInput") + recipientResolutionState = RecipientResolutionState.Error( + "Failed to resolve handle: ${e.message}" + ) + } + } isMatrixUser -> { // Start lookup recipientResolutionState = RecipientResolutionState.Resolving(recipientInput) @@ -200,7 +234,8 @@ class PaymentEntryPresenter @AssistedInject constructor( val isCardanoAddress = CARDANO_ADDRESS_REGEX.matches(recipientInput) val isMatrixUser = MATRIX_USER_REGEX.matches(recipientInput) - val recipientError = validateRecipient(recipientInput, isCardanoAddress, isMatrixUser, recipientResolutionState) + val isHandle = HANDLE_REGEX.matches(recipientInput) + val recipientError = validateRecipient(recipientInput, isCardanoAddress, isMatrixUser, isHandle, recipientResolutionState) // Recipient is valid if we have a final resolved address val isValidRecipient = finalResolvedAddress != null @@ -291,10 +326,21 @@ class PaymentEntryPresenter @AssistedInject constructor( input: String, isCardanoAddress: Boolean, isMatrixUser: Boolean, + isHandle: Boolean, resolutionState: RecipientResolutionState ): String? { if (input.isBlank()) return null + // ADA Handle with ongoing resolution + if (isHandle) { + return when (resolutionState) { + is RecipientResolutionState.Resolving -> null // Still resolving + is RecipientResolutionState.HandleResolved -> null // Found address + is RecipientResolutionState.Error -> resolutionState.message + else -> null + } + } + // Matrix user with ongoing resolution if (isMatrixUser) { return when (resolutionState) { @@ -306,8 +352,8 @@ class PaymentEntryPresenter @AssistedInject constructor( } } - if (!isCardanoAddress && !isMatrixUser) { - return "Enter a Cardano address (addr1...) or Matrix user (@user:server)" + if (!isCardanoAddress && !isMatrixUser && !isHandle) { + return "Enter a Cardano address (addr1...), Matrix user (@user:server), or ADA Handle (\$handle)" } if (isCardanoAddress && input.length < 50) { return "Address too short" diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryState.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryState.kt index 7c02777c10..a5c8aa00d1 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryState.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryState.kt @@ -74,10 +74,10 @@ data class PaymentEntryState( } /** - * State of resolving a Matrix user ID to a Cardano address. + * State of resolving a Matrix user ID or ADA Handle to a Cardano address. */ sealed interface RecipientResolutionState { - /** Not a Matrix user ID - no resolution needed. */ + /** Not a Matrix user ID or ADA Handle - no resolution needed. */ data object NotNeeded : RecipientResolutionState /** Currently looking up the user's Cardano address. */ @@ -96,6 +96,12 @@ sealed interface RecipientResolutionState { /** Successfully resolved to a Cardano address (manual entry or from lookup). */ data class Resolved(val address: String) : RecipientResolutionState + /** Resolved from ADA Handle ($handle). */ + data class HandleResolved( + val handle: String, + val address: String, + ) : RecipientResolutionState + /** Failed to look up address. */ data class Error(val message: String) : RecipientResolutionState } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryView.kt index 54d9912a96..a7c07dc045 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryView.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryView.kt @@ -199,7 +199,7 @@ private fun PaymentFormContent( value = state.recipientInput, onValueChange = { state.eventSink(PaymentFlowEvents.RecipientChanged(it)) }, label = { Text("Recipient") }, - placeholder = { Text("addr1... or @user:server") }, + placeholder = { Text("addr1..., @user:server, or \$handle") }, isError = state.recipientError != null, supportingText = state.recipientError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, singleLine = true, @@ -217,6 +217,12 @@ private fun PaymentFormContent( address = resolution.address, ) } + is RecipientResolutionState.HandleResolved -> { + HandleResolvedCard( + handle = resolution.handle, + address = resolution.address, + ) + } is RecipientResolutionState.NeedsManualEntry -> { ManualAddressEntryCard( matrixUserId = resolution.matrixUserId, @@ -356,6 +362,47 @@ private fun AddressFoundCard(matrixUserId: String, address: String, modifier: Mo } } +/** + * Card shown when an ADA Handle has been resolved to an address. + */ +@Composable +private fun HandleResolvedCard(handle: String, address: String, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = CompoundIcons.Check(), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = "Resolved from $handle ✓", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + // Show truncated address + val truncatedAddress = if (address.length > 24) { + "${address.take(12)}...${address.takeLast(8)}" + } else { + address + } + Text( + text = truncatedAddress, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), + ) + } + } +} + /** * Card shown when the Matrix user has no linked Cardano wallet. * Includes a text field for manual address entry. @@ -469,6 +516,21 @@ internal class PaymentEntryStateProvider : PreviewParameterProvider() var assets = mutableMapOf>() var transactions = mutableMapOf>() + var handles = mutableMapOf() // handle name (without $) -> address // Error simulation var shouldFailWithNetworkError = false var shouldFailWithRateLimit = false var submitShouldFail = false var submitErrorMessage: String? = null + var handleResolutionShouldFail = false // Protocol parameters (configurable) var protocolParameters = ProtocolParameters( @@ -61,6 +64,8 @@ class FakeCardanoClient : CardanoClient { private set var getAddressTransactionsCallCount = 0 private set + var resolveHandleCallCount = 0 + private set /** * Represents a submitted transaction for testing. @@ -179,6 +184,25 @@ class FakeCardanoClient : CardanoClient { return Result.success(transactions[address]?.take(limit) ?: emptyList()) } + override suspend fun resolveHandle(handle: String): Result { + resolveHandleCallCount++ + + if (shouldFailWithNetworkError) { + return Result.failure(CardanoException.NetworkException("Simulated network error")) + } + if (shouldFailWithRateLimit) { + return Result.failure(CardanoException.RateLimitException(retryAfterMs = 1000L)) + } + if (handleResolutionShouldFail) { + return Result.failure(CardanoException.ApiException("Simulated handle resolution failure", "")) + } + + // Normalize to lowercase + val normalizedHandle = handle.lowercase().trim() + val address = handles[normalizedHandle] + return Result.success(address) + } + // Helper methods for test setup /** @@ -238,6 +262,13 @@ class FakeCardanoClient : CardanoClient { submitErrorMessage = errorMessage } + /** + * Configures an ADA Handle to resolve to a specific address. + */ + fun givenHandle(handle: String, address: String) { + handles[handle.lowercase().trim()] = address + } + /** * Resets all state and counters. */ @@ -248,10 +279,12 @@ class FakeCardanoClient : CardanoClient { submittedTransactions.clear() assets.clear() transactions.clear() + handles.clear() shouldFailWithNetworkError = false shouldFailWithRateLimit = false submitShouldFail = false submitErrorMessage = null + handleResolutionShouldFail = false getBalanceCallCount = 0 getUtxosCallCount = 0 submitTxCallCount = 0 @@ -259,6 +292,7 @@ class FakeCardanoClient : CardanoClient { getProtocolParametersCallCount = 0 getAddressAssetsCallCount = 0 getAddressTransactionsCallCount = 0 + resolveHandleCallCount = 0 protocolParameters = ProtocolParameters( minFeeA = 44L, minFeeB = 155381L,