diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/address/CardanoAddressService.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/address/CardanoAddressService.kt new file mode 100644 index 0000000000..2464440fa5 --- /dev/null +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/address/CardanoAddressService.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.api.address + +import io.element.android.libraries.matrix.api.core.UserId + +/** + * Service for managing Cardano addresses in Matrix account data. + * + * This allows users to publish their Cardano address so other users can + * look it up for payments - like a public address directory baked into Matrix. + * + * Account data key: `com.sulkta.cardano.address` + * Content format: `{ "address": "addr1..." }` + */ +interface CardanoAddressService { + /** + * Publish the user's Cardano address to their Matrix account data. + * This is public data, not encrypted. + * + * @param address The Cardano address to publish + * @return Result indicating success or failure + */ + suspend fun publishAddress(address: String): Result + + /** + * Look up another user's Cardano address from their Matrix account data. + * + * @param userId The Matrix user ID to look up + * @return The user's Cardano address if published, null if not found + */ + suspend fun lookupAddress(userId: UserId): Result + + companion object { + const val ACCOUNT_DATA_TYPE = "com.sulkta.cardano.address" + } +} + +/** + * Result of a Cardano address lookup. + */ +sealed interface AddressLookupResult { + /** Address was found and retrieved successfully */ + data class Found(val address: String, val userId: UserId) : AddressLookupResult + + /** User has no Cardano address linked */ + data class NotLinked(val userId: UserId) : AddressLookupResult + + /** Lookup failed due to an error */ + data class Error(val userId: UserId, val message: String) : AddressLookupResult +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/address/DefaultCardanoAddressService.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/address/DefaultCardanoAddressService.kt new file mode 100644 index 0000000000..5f6ccc3d84 --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/address/DefaultCardanoAddressService.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.address + +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import io.element.android.features.wallet.api.address.CardanoAddressService +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import timber.log.Timber +import java.util.concurrent.TimeUnit + +/** + * Implementation of [CardanoAddressService] that stores Cardano addresses + * in Matrix account data for public discovery. + */ +@ContributesBinding(SessionScope::class) +class DefaultCardanoAddressService @Inject constructor( + private val matrixClient: MatrixClient, + private val sessionStore: SessionStore, + private val dispatchers: CoroutineDispatchers, +) : CardanoAddressService { + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val httpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + } + + @Serializable + private data class CardanoAddressData( + val address: String + ) + + override suspend fun publishAddress(address: String): Result = withContext(dispatchers.io) { + runCatching { + val sessionData = sessionStore.getSession(matrixClient.sessionId.value) + ?: throw IllegalStateException("No session found") + + val userId = matrixClient.sessionId.value + val url = "${sessionData.homeserverUrl}/_matrix/client/v3/user/$userId/account_data/${CardanoAddressService.ACCOUNT_DATA_TYPE}" + + val body = json.encodeToString(CardanoAddressData(address)) + .toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url(url) + .put(body) + .addHeader("Authorization", "Bearer ${sessionData.accessToken}") + .build() + + Timber.d("Publishing Cardano address to Matrix account data...") + + val response = httpClient.newCall(request).execute() + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + throw RuntimeException("Failed to publish address: ${response.code} - $errorBody") + } + + Timber.i("Successfully published Cardano address to Matrix account data") + } + } + + override suspend fun lookupAddress(userId: UserId): Result = withContext(dispatchers.io) { + runCatching { + val sessionData = sessionStore.getSession(matrixClient.sessionId.value) + ?: throw IllegalStateException("No session found") + + val url = "${sessionData.homeserverUrl}/_matrix/client/v3/user/${userId.value}/account_data/${CardanoAddressService.ACCOUNT_DATA_TYPE}" + + Timber.d("Looking up Cardano address for ${userId.value}...") + + val request = Request.Builder() + .url(url) + .get() + .addHeader("Authorization", "Bearer ${sessionData.accessToken}") + .build() + + val response = httpClient.newCall(request).execute() + + when (response.code) { + 200 -> { + val responseBody = response.body?.string() + if (responseBody != null) { + val data = json.decodeFromString(responseBody) + Timber.i("Found Cardano address for ${userId.value}: ${data.address.take(20)}...") + data.address + } else { + null + } + } + 404 -> { + Timber.d("No Cardano address found for ${userId.value}") + null + } + else -> { + val errorBody = response.body?.string() ?: "Unknown error" + throw RuntimeException("Failed to lookup address: ${response.code} - $errorBody") + } + } + } + } +} 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 b4d5281107..a77559c95c 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 @@ -17,6 +17,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.features.wallet.api.CardanoClient +import io.element.android.features.wallet.api.address.CardanoAddressService import io.element.android.features.wallet.impl.cardano.CardanoNetwork import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig import io.element.android.features.wallet.impl.cardano.CardanoWalletManager @@ -25,6 +26,8 @@ import io.element.android.features.wallet.impl.slash.ParsedPayCommand import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import timber.log.Timber import java.math.BigDecimal /** @@ -36,6 +39,7 @@ class PaymentEntryPresenter @AssistedInject constructor( private val matrixClient: MatrixClient, private val walletManager: CardanoWalletManager, private val cardanoClient: CardanoClient, + private val cardanoAddressService: CardanoAddressService, ) : Presenter { @AssistedFactory @@ -44,6 +48,7 @@ class PaymentEntryPresenter @AssistedInject constructor( } companion object { + private const val TAG = "PaymentEntryPresenter" private const val LOVELACE_PER_ADA = 1_000_000L private const val MIN_AMOUNT_LOVELACE = 1_000_000L private const val MAX_ADA_SUPPLY = 45_000_000_000L @@ -100,6 +105,8 @@ class PaymentEntryPresenter @AssistedInject constructor( var senderAddress by remember { mutableStateOf(null) } var senderBalanceLovelace by remember { mutableStateOf(null) } var recipientResolutionState by remember { mutableStateOf(RecipientResolutionState.NotNeeded) } + // Track resolved address separately so we can use it for validation + var resolvedCardanoAddress by remember { mutableStateOf(null) } LaunchedEffect(walletInitialized) { if (walletInitialized) { @@ -113,26 +120,69 @@ class PaymentEntryPresenter @AssistedInject constructor( } } + // Look up Cardano address when a Matrix user is entered + LaunchedEffect(recipientInput) { + val isMatrixUser = MATRIX_USER_REGEX.matches(recipientInput) + val isCardanoAddress = CARDANO_ADDRESS_REGEX.matches(recipientInput) + + when { + recipientInput.isBlank() -> { + recipientResolutionState = RecipientResolutionState.NotNeeded + resolvedCardanoAddress = null + } + isCardanoAddress -> { + recipientResolutionState = RecipientResolutionState.NotNeeded + resolvedCardanoAddress = recipientInput + } + isMatrixUser -> { + // Start lookup + recipientResolutionState = RecipientResolutionState.Resolving(recipientInput) + resolvedCardanoAddress = null + + Timber.tag(TAG).d("Looking up Cardano address for $recipientInput...") + + val userId = UserId(recipientInput) + cardanoAddressService.lookupAddress(userId) + .onSuccess { address -> + if (address != null) { + Timber.tag(TAG).i("Found Cardano address for $recipientInput") + recipientResolutionState = RecipientResolutionState.Found( + matrixUserId = recipientInput, + address = address + ) + resolvedCardanoAddress = address + } else { + Timber.tag(TAG).d("No Cardano address linked for $recipientInput") + recipientResolutionState = RecipientResolutionState.NeedsManualEntry( + matrixUserId = recipientInput, + displayName = null + ) + } + } + .onFailure { e -> + Timber.tag(TAG).w(e, "Failed to lookup address for $recipientInput") + recipientResolutionState = RecipientResolutionState.NeedsManualEntry( + matrixUserId = recipientInput, + displayName = null + ) + } + } + else -> { + recipientResolutionState = RecipientResolutionState.NotNeeded + resolvedCardanoAddress = null + } + } + } + val parsedAmountLovelace = parseAmountInput(amountInput) val amountError = validateAmount(parsedAmountLovelace, amountInput) val isCardanoAddress = CARDANO_ADDRESS_REGEX.matches(recipientInput) val isMatrixUser = MATRIX_USER_REGEX.matches(recipientInput) - val recipientError = validateRecipient(recipientInput, isCardanoAddress, isMatrixUser) + val recipientError = validateRecipient(recipientInput, isCardanoAddress, isMatrixUser, recipientResolutionState) - LaunchedEffect(recipientInput, isMatrixUser, isCardanoAddress) { - recipientResolutionState = when { - recipientInput.isBlank() -> RecipientResolutionState.NotNeeded - isCardanoAddress -> RecipientResolutionState.NotNeeded - isMatrixUser -> RecipientResolutionState.NeedsManualEntry( - matrixUserId = recipientInput, - displayName = null - ) - else -> RecipientResolutionState.NotNeeded - } - } - - val isValidRecipient = isCardanoAddress + // Recipient is valid if we have a direct Cardano address or a resolved one from Matrix lookup + val isValidRecipient = isCardanoAddress || resolvedCardanoAddress != null val canContinue = parsedAmountLovelace != null && parsedAmountLovelace >= MIN_AMOUNT_LOVELACE && amountError == null && @@ -142,7 +192,11 @@ class PaymentEntryPresenter @AssistedInject constructor( fun handleEvent(event: PaymentFlowEvents) { when (event) { is PaymentFlowEvents.AmountChanged -> amountInput = event.amount - is PaymentFlowEvents.RecipientChanged -> recipientInput = event.recipient + is PaymentFlowEvents.RecipientChanged -> { + recipientInput = event.recipient + // Clear resolved address when input changes + resolvedCardanoAddress = null + } else -> Unit } } @@ -205,8 +259,25 @@ class PaymentEntryPresenter @AssistedInject constructor( return null } - private fun validateRecipient(input: String, isCardanoAddress: Boolean, isMatrixUser: Boolean): String? { + private fun validateRecipient( + input: String, + isCardanoAddress: Boolean, + isMatrixUser: Boolean, + resolutionState: RecipientResolutionState + ): String? { if (input.isBlank()) return null + + // Matrix user with ongoing resolution + if (isMatrixUser) { + return when (resolutionState) { + is RecipientResolutionState.Resolving -> null // Still looking up + is RecipientResolutionState.Found -> null // Found address + is RecipientResolutionState.NeedsManualEntry -> "${resolutionState.matrixUserId} hasn't linked a Cardano wallet" + is RecipientResolutionState.Error -> resolutionState.message + else -> null + } + } + if (!isCardanoAddress && !isMatrixUser) { return "Enter a Cardano address (addr1...) or Matrix user (@user:server)" } 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 238d00dd8b..71d2db926d 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 @@ -64,8 +64,25 @@ data class PaymentEntryState( * State of resolving a Matrix user ID to a Cardano address. */ sealed interface RecipientResolutionState { + /** Not a Matrix user ID - no resolution needed. */ data object NotNeeded : RecipientResolutionState + + /** Currently looking up the user's Cardano address. */ + data class Resolving(val matrixUserId: String) : RecipientResolutionState + + /** Found the user's Cardano address from their Matrix profile. */ + data class Found( + val matrixUserId: String, + val address: String, + val displayName: String? = null + ) : RecipientResolutionState + + /** User has no Cardano address linked - needs manual entry. */ data class NeedsManualEntry(val matrixUserId: String, val displayName: String?) : RecipientResolutionState + + /** Successfully resolved to a Cardano address (manual entry or from lookup). */ data class Resolved(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 5357f69ff6..24cdb86e61 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 @@ -42,6 +42,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button @@ -205,7 +206,17 @@ private fun PaymentFormContent( modifier = Modifier.fillMaxWidth(), ) + // Show resolution state feedback when (val resolution = state.recipientResolutionState) { + is RecipientResolutionState.Resolving -> { + AddressLookupInProgressCard(matrixUserId = resolution.matrixUserId) + } + is RecipientResolutionState.Found -> { + AddressFoundCard( + matrixUserId = resolution.matrixUserId, + address = resolution.address, + ) + } is RecipientResolutionState.NeedsManualEntry -> { MatrixUserNeedsAddressCard( matrixUserId = resolution.matrixUserId, @@ -266,6 +277,66 @@ private fun BalanceInfoCard(balanceAda: String, modifier: Modifier = Modifier) { } } +@Composable +private fun AddressLookupInProgressCard(matrixUserId: String, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Text( + text = "Looking up address for $matrixUserId...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun AddressFoundCard(matrixUserId: 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, + ) + val displayName = matrixUserId.substringBefore(":").removePrefix("@") + Text( + text = "Address loaded from $displayName's profile", + 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), + ) + } + } +} + @Composable private fun MatrixUserNeedsAddressCard(matrixUserId: String, displayName: String?, modifier: Modifier = Modifier) { Card( @@ -296,6 +367,15 @@ internal class PaymentEntryStateProvider : PreviewParameterProvider { companion object { @@ -35,6 +37,20 @@ class WalletSetupPresenter @Inject constructor( private val VALID_WORD_COUNTS = listOf(12, 15, 18, 21, 24) } + /** + * Publish the Cardano address to Matrix account data for discovery. + * This is fire-and-forget - we don't fail the wallet setup if publishing fails. + */ + private suspend fun publishAddressToMatrix(address: String) { + cardanoAddressService.publishAddress(address) + .onSuccess { + Timber.tag(TAG).i("Published Cardano address to Matrix account data") + } + .onFailure { e -> + Timber.tag(TAG).w(e, "Failed to publish Cardano address (non-fatal)") + } + } + @Composable override fun present(): WalletSetupState { val scope = rememberCoroutineScope() @@ -144,6 +160,8 @@ class WalletSetupPresenter @Inject constructor( generatedMnemonic = words generatedAddress = address isImporting = false + // Publish address to Matrix for discovery + publishAddressToMatrix(address) // Skip to address confirmation (no backup prompt for imported wallets // since user already has their phrase) step = SetupStep.SHOW_ADDRESS @@ -192,6 +210,8 @@ class WalletSetupPresenter @Inject constructor( generatedMnemonic = mnemonic generatedAddress = address isRestoringFromCloud = false + // Publish address to Matrix for discovery + publishAddressToMatrix(address) // Go directly to address confirmation step = SetupStep.SHOW_ADDRESS } @@ -232,6 +252,8 @@ class WalletSetupPresenter @Inject constructor( hasConfirmedBackup = true step = SetupStep.COMPLETE scope.launch { + // Publish address to Matrix for discovery + generatedAddress?.let { publishAddressToMatrix(it) } walletManager.initialize(sessionId) } } @@ -256,6 +278,8 @@ class WalletSetupPresenter @Inject constructor( isBackingUp = false hasConfirmedBackup = true step = SetupStep.COMPLETE + // Publish address to Matrix for discovery + generatedAddress?.let { publishAddressToMatrix(it) } walletManager.initialize(sessionId) } .onFailure { e -> @@ -276,6 +300,8 @@ class WalletSetupPresenter @Inject constructor( hasConfirmedBackup = true step = SetupStep.COMPLETE scope.launch { + // Publish address to Matrix for discovery + generatedAddress?.let { publishAddressToMatrix(it) } walletManager.initialize(sessionId) } }