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 0b291cddde..5326ac09e5 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 @@ -81,4 +81,15 @@ interface CardanoClient { * @return Bech32 Cardano address if handle exists, null if not found */ suspend fun resolveHandle(handle: String): Result + + /** + * Get CIP-25 NFT metadata for a specific asset. + * + * Uses the Koios asset_info endpoint to fetch onchain_metadata. + * + * @param policyId The minting policy ID (hex, 56 chars) + * @param assetName The asset name (hex encoded) + * @return [NftMetadata] if CIP-25 metadata exists, null otherwise + */ + suspend fun getNftMetadata(policyId: String, assetName: String): Result } diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/NftMetadata.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/NftMetadata.kt new file mode 100644 index 0000000000..3cc6c1dee3 --- /dev/null +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/NftMetadata.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.api + +/** + * CIP-25 NFT metadata parsed from Koios asset_info response. + * + * @property name The NFT name + * @property image Resolved HTTP URL for the image (IPFS gateway or direct HTTPS) + * @property description NFT description if available + * @property rawMetadata Original metadata map for additional fields + */ +data class NftMetadata( + val name: String, + val image: String?, + val description: String?, + val rawMetadata: Map, +) { + companion object { + private const val IPFS_GATEWAY = "https://ipfs.io/ipfs/" + + /** + * Resolve IPFS URLs to HTTP gateway URLs. + */ + fun resolveImageUrl(url: String?): String? { + if (url == null) return null + return when { + url.startsWith("ipfs://") -> IPFS_GATEWAY + url.removePrefix("ipfs://") + url.startsWith("Qm") -> IPFS_GATEWAY + url // Direct IPFS hash + url.startsWith("https://") || url.startsWith("http://") -> url + else -> null + } + } + + /** + * Join array-based image URL (some NFTs split the URL across multiple strings). + */ + fun joinImageParts(parts: List?): String? { + if (parts.isNullOrEmpty()) return null + return resolveImageUrl(parts.joinToString("")) + } + } +} diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index ed3a49f2f4..3ce12b70a6 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -50,6 +50,10 @@ dependencies { // Coroutines implementation(libs.coroutines.core) + // Image loading for NFT thumbnails + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + // Testing testImplementation(projects.features.wallet.test) testImplementation(projects.libraries.matrix.test) 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 76b5d0d216..466f496e20 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 @@ -13,6 +13,7 @@ import dev.zacsweers.metro.Inject import io.element.android.features.wallet.api.CardanoClient import io.element.android.features.wallet.api.CardanoException import io.element.android.features.wallet.api.NativeAsset +import io.element.android.features.wallet.api.NftMetadata import io.element.android.features.wallet.api.ProtocolParameters import io.element.android.features.wallet.api.TxStatus import io.element.android.features.wallet.api.TxSummary @@ -73,6 +74,9 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient { private data class CachedHandle(val address: String?, val timestamp: Long) private val handleCache = mutableMapOf() + // NFT metadata cache + private val nftMetadataCache = mutableMapOf() + override suspend fun getBalance(address: String): Result = withRetry("getBalance($address)") { withContext(Dispatchers.IO) { @@ -306,12 +310,14 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient { val assets = assetMap.map { (key, quantity) -> val policyId = key.take(56) val assetNameHex = key.drop(56) + // Mark as potential NFT if quantity is 1 NativeAsset( policyId = policyId, assetName = assetNameHex, quantity = quantity, displayName = null, fingerprint = null, + isNft = quantity == 1L, ) } @@ -414,6 +420,152 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient { } } + override suspend fun getNftMetadata(policyId: String, assetName: String): Result = + withRetry("getNftMetadata($policyId, $assetName)") { + withContext(Dispatchers.IO) { + val cacheKey = "$policyId$assetName" + + // Check cache first + if (nftMetadataCache.containsKey(cacheKey)) { + return@withContext Result.success(nftMetadataCache[cacheKey]) + } + + throttleRequest() + + val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}asset_info" + val body = JSONObject().apply { + put("_asset_list", JSONArray().put(JSONArray().apply { + put(policyId) + put(assetName) + })) + }.toString() + + Timber.tag(TAG).d("getNftMetadata calling: $url with policy=$policyId, asset=$assetName") + + 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("getNftMetadata response: code=${response.code}, body=${responseBody.take(1000)}") + + if (!response.isSuccessful) { + return@withContext Result.failure(parseHttpError(response.code, responseBody)) + } + + val jsonArray = JSONArray(responseBody) + if (jsonArray.length() == 0) { + nftMetadataCache[cacheKey] = null + return@withContext Result.success(null) + } + + val assetInfo = jsonArray.getJSONObject(0) + + // Parse CIP-25 onchain_metadata + val metadata = try { + parseCip25Metadata(assetInfo) + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to parse CIP-25 metadata") + null + } + + nftMetadataCache[cacheKey] = metadata + Result.success(metadata) + } + } + + /** + * Parse CIP-25 metadata from Koios asset_info response. + */ + private fun parseCip25Metadata(assetInfo: JSONObject): NftMetadata? { + // Check for onchain_metadata (CIP-25) + val onchainMetadata = assetInfo.optJSONObject("onchain_metadata") ?: return null + + // Get asset name for lookup (decoded) + val assetNameHex = assetInfo.optString("asset_name", "") + val assetNameDecoded = assetInfo.optString("asset_name_ascii", "") + + // Extract name - could be in various places + val name = onchainMetadata.optString("name") + .takeIf { it.isNotEmpty() } + ?: assetNameDecoded.takeIf { it.isNotEmpty() } + ?: assetNameHex + + // Extract image - handle both string and array formats + val imageUrl = extractImageUrl(onchainMetadata) + + // Extract description + val description = onchainMetadata.optString("description") + .takeIf { it.isNotEmpty() } + + // Build raw metadata map + val rawMetadata = mutableMapOf() + onchainMetadata.keys().forEach { key -> + val value = onchainMetadata.get(key) + if (value != null && value != JSONObject.NULL) { + rawMetadata[key] = convertJsonValue(value) + } + } + + return NftMetadata( + name = name, + image = imageUrl, + description = description, + rawMetadata = rawMetadata, + ) + } + + /** + * Extract image URL from CIP-25 metadata, handling various formats. + */ + private fun extractImageUrl(metadata: JSONObject): String? { + return try { + when (val imageValue = metadata.opt("image")) { + is String -> NftMetadata.resolveImageUrl(imageValue) + is JSONArray -> { + // Some NFTs split the URL across multiple array elements + val parts = (0 until imageValue.length()).mapNotNull { + imageValue.optString(it).takeIf { s -> s.isNotEmpty() } + } + NftMetadata.joinImageParts(parts) + } + else -> null + } + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to extract image URL") + null + } + } + + /** + * Convert JSON value to Kotlin type for raw metadata map. + */ + private fun convertJsonValue(value: Any): Any { + return when (value) { + is JSONObject -> { + val map = mutableMapOf() + value.keys().forEach { key -> + val v = value.get(key) + if (v != null && v != JSONObject.NULL) { + map[key] = convertJsonValue(v) + } + } + map + } + is JSONArray -> { + (0 until value.length()).mapNotNull { i -> + val v = value.opt(i) + if (v != null && v != JSONObject.NULL) convertJsonValue(v) else null + } + } + else -> value + } + } + private suspend fun withRetry( operation: String, block: suspend () -> Result, diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelNode.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelNode.kt index 4c1a55449a..e8db906a81 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelNode.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelNode.kt @@ -18,7 +18,6 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig -import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.callback import io.element.android.libraries.di.SessionScope diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt index be058e7e89..6c9da498a2 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject import io.element.android.features.wallet.api.CardanoClient import io.element.android.features.wallet.api.NativeAsset +import io.element.android.features.wallet.api.NftMetadata import io.element.android.features.wallet.api.TxSummary import io.element.android.features.wallet.api.backup.WalletBackupService import io.element.android.features.wallet.api.storage.CardanoKeyStorage @@ -24,6 +25,9 @@ import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig import io.element.android.features.wallet.impl.cardano.CardanoWalletManager import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.MatrixClient +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import timber.log.Timber @@ -83,9 +87,11 @@ class WalletPanelPresenter @Inject constructor( walletManager.refreshBalance(matrixClient.sessionId, balance) } - // Fetch assets + // Fetch assets and enrich with NFT metadata cardanoClient.getAddressAssets(address) - .onSuccess { assets = it } + .onSuccess { fetchedAssets -> + assets = enrichAssetsWithMetadata(fetchedAssets) + } .onFailure { Timber.w(it, "Failed to fetch assets") } // Fetch transactions @@ -280,4 +286,59 @@ class WalletPanelPresenter @Inject constructor( eventSink = ::handleEvent, ) } + + /** + * Enrich assets with NFT metadata from Koios. + * Fetches CIP-25 metadata for potential NFTs (quantity == 1) in parallel. + */ + private suspend fun enrichAssetsWithMetadata(assets: List): List { + if (assets.isEmpty()) return assets + + // Identify potential NFTs (quantity == 1 or marked as NFT) + val potentialNfts = assets.filter { it.quantity == 1L || it.isNft } + if (potentialNfts.isEmpty()) return assets + + Timber.d("Enriching ${potentialNfts.size} potential NFTs with metadata") + + // Fetch metadata in parallel (max 10 concurrent to avoid rate limiting) + val metadataMap = mutableMapOf() + try { + coroutineScope { + potentialNfts.chunked(10).forEach { chunk -> + chunk.map { asset -> + async { + cardanoClient.getNftMetadata(asset.policyId, asset.assetName) + .onSuccess { metadata -> + if (metadata != null) { + metadataMap[asset.unit] = metadata + } + } + .onFailure { e -> + Timber.w(e, "Failed to fetch metadata for ${asset.unit}") + } + } + }.awaitAll() + } + } + } catch (e: Exception) { + Timber.w(e, "Error during metadata enrichment, continuing without full metadata") + } + + Timber.d("Successfully fetched metadata for ${metadataMap.size} NFTs") + + // Apply metadata to assets + return assets.map { asset -> + val metadata = metadataMap[asset.unit] + if (metadata != null) { + asset.copy( + displayName = metadata.name.takeIf { it.isNotEmpty() } ?: asset.displayName, + imageUrl = metadata.image, + description = metadata.description, + isNft = metadata.image != null, + ) + } else { + asset + } + } + } } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/AssetsTabView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/AssetsTabView.kt index 5c6d9f1927..2c76af340e 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/AssetsTabView.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/AssetsTabView.kt @@ -6,25 +6,44 @@ package io.element.android.features.wallet.impl.panel.tabs +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.wallet.api.NativeAsset import io.element.android.features.wallet.impl.R @@ -38,6 +57,8 @@ fun AssetsTabView( isLoading: Boolean, modifier: Modifier = Modifier, ) { + var selectedNft by remember { mutableStateOf(null) } + Box(modifier = modifier) { when { isLoading -> { @@ -73,29 +94,77 @@ fun AssetsTabView( verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(assets) { asset -> - AssetCard(asset = asset) + AssetCard( + asset = asset, + onClick = { if (asset.isNft || asset.imageUrl != null) selectedNft = asset }, + ) } } } } } + + // NFT Detail Bottom Sheet + selectedNft?.let { nft -> + NftDetailBottomSheet( + asset = nft, + onDismiss = { selectedNft = null }, + ) + } } @Composable private fun AssetCard( asset: NativeAsset, + onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val hasImage = asset.imageUrl != null + Card( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .then( + if (hasImage) { + Modifier.clickable(onClick = onClick) + } else { + Modifier + } + ), ) { Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { + // NFT Thumbnail (64dp square, 8dp rounded corners) + if (hasImage) { + NftThumbnail( + imageUrl = asset.imageUrl!!, + contentDescription = asset.name, + modifier = Modifier.size(64.dp), + ) + } else { + // Placeholder for non-NFT tokens + Box( + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = CompoundIcons.Info(), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + } + + // Asset info Column( modifier = Modifier.weight(1f), ) { @@ -104,21 +173,208 @@ private fun AssetCard( style = MaterialTheme.typography.bodyLarge.copy( fontWeight = FontWeight.Medium, ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Text( text = asset.truncatedPolicyId, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) + if (asset.isNft) { + Text( + text = "NFT", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } } + + // Quantity Text( - text = asset.quantity.toString(), + text = asset.formatQuantity(), style = MaterialTheme.typography.titleMedium, ) } } } +@Composable +private fun NftThumbnail( + imageUrl: String, + contentDescription: String?, + modifier: Modifier = Modifier, +) { + var isLoading by remember { mutableStateOf(true) } + var isError by remember { mutableStateOf(false) } + + Box( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = imageUrl, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + onState = { state -> + isLoading = state is AsyncImagePainter.State.Loading + isError = state is AsyncImagePainter.State.Error + }, + ) + + // Loading indicator + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } + + // Error placeholder + if (isError) { + Icon( + imageVector = CompoundIcons.Error(), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NftDetailBottomSheet( + asset: NativeAsset, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Large NFT image + asset.imageUrl?.let { url -> + NftDetailImage( + imageUrl = url, + contentDescription = asset.name, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .padding(bottom = 16.dp), + ) + } + + // NFT Name + Text( + text = asset.name, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp), + ) + + // Policy ID + Text( + text = asset.truncatedPolicyId, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp), + ) + + // Description if available + asset.description?.let { description -> + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp), + ) + } + + // Quantity badge + Row( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Quantity: ${asset.formatQuantity()}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } +} + +@Composable +private fun NftDetailImage( + imageUrl: String, + contentDescription: String?, + modifier: Modifier = Modifier, +) { + var isLoading by remember { mutableStateOf(true) } + var isError by remember { mutableStateOf(false) } + + Box( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = imageUrl, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + onState = { state -> + isLoading = state is AsyncImagePainter.State.Loading + isError = state is AsyncImagePainter.State.Error + }, + ) + + // Loading indicator + if (isLoading) { + CircularProgressIndicator() + } + + // Error placeholder + if (isError) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = CompoundIcons.Error(), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Failed to load image", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @PreviewsDayNight @Composable internal fun AssetsTabViewPreview() = ElementPreview { @@ -133,10 +389,13 @@ internal fun AssetsTabViewPreview() = ElementPreview { ), NativeAsset( policyId = "11223344556677889900aabbccdd11223344556677889900aabbccdd", - assetName = "", - quantity = 5, - displayName = null, + assetName = "436f6f6c4e4654", + quantity = 1, + displayName = "CoolNFT", fingerprint = null, + imageUrl = "https://ipfs.io/ipfs/QmTest123", + isNft = true, + description = "A really cool NFT from the Cardano blockchain", ), ), isLoading = false, diff --git a/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeCardanoClient.kt b/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeCardanoClient.kt index 0d7f94a6a6..9cbd58a709 100644 --- a/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeCardanoClient.kt +++ b/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeCardanoClient.kt @@ -9,6 +9,7 @@ package io.element.android.features.wallet.test import io.element.android.features.wallet.api.CardanoClient import io.element.android.features.wallet.api.CardanoException import io.element.android.features.wallet.api.NativeAsset +import io.element.android.features.wallet.api.NftMetadata import io.element.android.features.wallet.api.ProtocolParameters import io.element.android.features.wallet.api.TxStatus import io.element.android.features.wallet.api.TxSummary @@ -24,6 +25,7 @@ import io.element.android.features.wallet.api.UtxoAsset * - Rate limiting * - Transaction lifecycle (pending → confirmed) * - ADA Handle resolution + * - NFT metadata */ class FakeCardanoClient : CardanoClient { // Configurable responses @@ -34,6 +36,7 @@ class FakeCardanoClient : CardanoClient { var assets = mutableMapOf>() var transactions = mutableMapOf>() var handles = mutableMapOf() // handle name (without $) -> address + var nftMetadata = mutableMapOf() // policyId+assetName -> metadata // Error simulation var shouldFailWithNetworkError = false @@ -67,6 +70,8 @@ class FakeCardanoClient : CardanoClient { private set var resolveHandleCallCount = 0 private set + var getNftMetadataCallCount = 0 + private set /** * Represents a submitted transaction for testing. @@ -204,6 +209,20 @@ class FakeCardanoClient : CardanoClient { return Result.success(address) } + override suspend fun getNftMetadata(policyId: String, assetName: String): Result { + getNftMetadataCallCount++ + + if (shouldFailWithNetworkError) { + return Result.failure(CardanoException.NetworkException("Simulated network error")) + } + if (shouldFailWithRateLimit) { + return Result.failure(CardanoException.RateLimitException(retryAfterMs = 1000L)) + } + + val key = "$policyId$assetName" + return Result.success(nftMetadata[key]) + } + // Helper methods for test setup /** @@ -270,6 +289,13 @@ class FakeCardanoClient : CardanoClient { handles[handle.lowercase().trim()] = address } + /** + * Configures NFT metadata for an asset. + */ + fun givenNftMetadata(policyId: String, assetName: String, metadata: NftMetadata) { + nftMetadata["$policyId$assetName"] = metadata + } + /** * Resets all state and counters. */ @@ -281,6 +307,7 @@ class FakeCardanoClient : CardanoClient { assets.clear() transactions.clear() handles.clear() + nftMetadata.clear() shouldFailWithNetworkError = false shouldFailWithRateLimit = false submitShouldFail = false @@ -294,6 +321,7 @@ class FakeCardanoClient : CardanoClient { getAddressAssetsCallCount = 0 getAddressTransactionsCallCount = 0 resolveHandleCallCount = 0 + getNftMetadataCallCount = 0 protocolParameters = ProtocolParameters( minFeeA = 44L, minFeeB = 155381L,