feat(wallet): store Cardano address in Matrix account data for discovery

Implements public Cardano address directory using Matrix account data:

Publishing (write side):
- After wallet creation, import, or SSSS restore, the Cardano address
  is written to the user Matrix account data
- Key: com.sulkta.cardano.address
- Content: { "address": "addr1..." }
- This is public/unencrypted for discovery by other users

Lookup (read side):
- When entering a Matrix user in /pay, their account data is checked
- If they have a linked Cardano address, it auto-fills the recipient
- UI shows "Address loaded from @username profile ✓" when found
- Shows "@username has not linked a wallet" if not found
- Graceful fallback to manual address entry

New files:
- CardanoAddressService interface (wallet:api)
- DefaultCardanoAddressService implementation (wallet:impl)

Updated:
- WalletSetupPresenter: calls publishAddress after all wallet setup paths
- PaymentEntryPresenter: looks up recipient address from Matrix
- PaymentEntryState: added Resolving and Found states
- PaymentEntryView: shows lookup progress and result cards
This commit is contained in:
Kayos 2026-03-29 07:08:09 -07:00
parent 699807e1bd
commit c35289a3bd
6 changed files with 390 additions and 16 deletions

View file

@ -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<Unit>
/**
* 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<String?>
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
}

View file

@ -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<Unit> = 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<String?> = 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<CardanoAddressData>(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")
}
}
}
}
}

View file

@ -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<PaymentEntryState> {
@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<String?>(null) }
var senderBalanceLovelace by remember { mutableStateOf<Lovelace?>(null) }
var recipientResolutionState by remember { mutableStateOf<RecipientResolutionState>(RecipientResolutionState.NotNeeded) }
// Track resolved address separately so we can use it for validation
var resolvedCardanoAddress by remember { mutableStateOf<String?>(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)"
}

View file

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

View file

@ -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<PaymentEntry
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = true,
amountError = null, recipientError = null, canContinue = false, eventSink = {},
),
// Address found from Matrix
PaymentEntryState(
noWalletSetup = false, isCheckingWallet = false,
amountInput = "10", recipientInput = "@alice:matrix.org", prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = 10_000_000L, isValidRecipient = true,
recipientResolutionState = RecipientResolutionState.Found("@alice:matrix.org", "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer"),
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = true,
amountError = null, recipientError = null, canContinue = true, eventSink = {},
),
// No wallet state
PaymentEntryState(
noWalletSetup = true, isCheckingWallet = false,

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.address.CardanoAddressService
import io.element.android.features.wallet.api.backup.WalletBackupService
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
import io.element.android.features.wallet.impl.cardano.CardanoWalletManager
@ -28,6 +29,7 @@ class WalletSetupPresenter @Inject constructor(
private val walletManager: CardanoWalletManager,
private val matrixClient: MatrixClient,
private val walletBackupService: WalletBackupService,
private val cardanoAddressService: CardanoAddressService,
) : Presenter<WalletSetupState> {
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)
}
}