feat(wallet): ADA Handle resolution ($handle → address)

- Add resolveHandle() to CardanoClient interface
- Implement via Koios asset_addresses API with Handle policy ID
- Add HandleResolved state to RecipientResolutionState
- Detect $handle prefix in PaymentEntryPresenter
- Show "Resolved from $handle ✓" card in PaymentEntryView
- 1-hour in-memory cache for handle lookups
- Case-insensitive handle resolution (normalize to lowercase)
- Add resolveHandle to FakeCardanoClient for testing
This commit is contained in:
Kayos 2026-03-29 10:43:55 -07:00
parent dde0dd9f4f
commit af05e51916
6 changed files with 231 additions and 7 deletions

View file

@ -70,4 +70,15 @@ interface CardanoClient {
* @return List of [TxSummary] objects, most recent first
*/
suspend fun getAddressTransactions(address: String, limit: Int = 20): Result<List<TxSummary>>
/**
* 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<String?>
}

View file

@ -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<String, CachedHandle>()
override suspend fun getBalance(address: String): Result<Long> =
withRetry("getBalance($address)") {
withContext(Dispatchers.IO) {
@ -334,6 +342,63 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
}
}
override suspend fun resolveHandle(handle: String): Result<String?> =
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 <T> withRetry(
operation: String,
block: suspend () -> Result<T>,

View file

@ -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"

View file

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

View file

@ -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<PaymentEntry
amountError = null, recipientError = null, manualAddressError = null,
canContinue = true, eventSink = {},
),
// Handle resolved
PaymentEntryState(
noWalletSetup = false, isCheckingWallet = false,
amountInput = "10", recipientInput = "\$cobb", manualAddressInput = "",
prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = 10_000_000L, isValidRecipient = true,
recipientResolutionState = RecipientResolutionState.HandleResolved(
"\$cobb",
"addr1q85l4twecj9erkh49uv73w0p5ywsu7q7su7hpk3pangzd4x4n5czgvze3u5zflj9v2a4ttmdhtfr2rfdx0g4pp6p0tzs0h79mz"
),
resolvedAddress = "addr1q85l4twecj9erkh49uv73w0p5ywsu7q7su7hpk3pangzd4x4n5czgvze3u5zflj9v2a4ttmdhtfr2rfdx0g4pp6p0tzs0h79mz",
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = false,
amountError = null, recipientError = null, manualAddressError = null,
canContinue = true, eventSink = {},
),
// Manual entry needed - empty
PaymentEntryState(
noWalletSetup = false, isCheckingWallet = false,

View file

@ -22,6 +22,7 @@ import io.element.android.features.wallet.api.Utxo
* - Network errors
* - Rate limiting
* - Transaction lifecycle (pending confirmed)
* - ADA Handle resolution
*/
class FakeCardanoClient : CardanoClient {
// Configurable responses
@ -31,12 +32,14 @@ class FakeCardanoClient : CardanoClient {
var submittedTransactions = mutableListOf<SubmittedTx>()
var assets = mutableMapOf<String, List<NativeAsset>>()
var transactions = mutableMapOf<String, List<TxSummary>>()
var handles = mutableMapOf<String, String>() // 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<String?> {
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,