feat(wallet): NFT thumbnails and metadata display in Assets tab

- Add NFT metadata fetching via Koios asset_info endpoint
- Parse CIP-25 onchain_metadata for image, name, description
- Convert IPFS URLs to ipfs.io gateway URLs
- Display 64dp thumbnails with 8dp rounded corners using Coil AsyncImage
- Add bottom sheet detail view for NFT expansion (larger image + metadata)
- Graceful fallback with placeholder icons on image load failure
- Load metadata in presenter, cache results for 30 minutes
- Parallel metadata fetching for better performance
This commit is contained in:
Kayos 2026-03-29 15:21:53 -07:00
parent a57fd79098
commit 2d8df4f23f
8 changed files with 572 additions and 11 deletions

View file

@ -81,4 +81,15 @@ interface CardanoClient {
* @return Bech32 Cardano address if handle exists, null if not found
*/
suspend fun resolveHandle(handle: String): Result<String?>
/**
* 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<NftMetadata?>
}

View file

@ -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<String, Any>,
) {
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>?): String? {
if (parts.isNullOrEmpty()) return null
return resolveImageUrl(parts.joinToString(""))
}
}
}

View file

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

View file

@ -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<String, CachedHandle>()
// NFT metadata cache
private val nftMetadataCache = mutableMapOf<String, NftMetadata?>()
override suspend fun getBalance(address: String): Result<Long> =
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<NftMetadata?> =
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<String, Any>()
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<String, Any>()
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 <T> withRetry(
operation: String,
block: suspend () -> Result<T>,

View file

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

View file

@ -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<NativeAsset>): List<NativeAsset> {
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<String, NftMetadata>()
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
}
}
}
}

View file

@ -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<NativeAsset?>(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,

View file

@ -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<String, List<NativeAsset>>()
var transactions = mutableMapOf<String, List<TxSummary>>()
var handles = mutableMapOf<String, String>() // handle name (without $) -> address
var nftMetadata = mutableMapOf<String, NftMetadata>() // 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<NftMetadata?> {
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,