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:
parent
dde0dd9f4f
commit
af05e51916
6 changed files with 231 additions and 7 deletions
|
|
@ -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?>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue