29 KiB
Phase 6 Implementation Plan: Token Support, ADA Handle Resolution, NFT Display
Research completed: 2026-03-29
Based on: Actual code review of Element X Android wallet module
Library version: cardano-client-lib 0.7.1
Executive Summary
Phase 6 adds three features:
- Token Send — Send native assets (Cardano tokens) alongside or instead of ADA
- ADA Handle Resolution — Resolve
$handleto Cardano addresses - NFT Display — Show NFT thumbnails in the Assets tab
All three are feasible with the current stack. No library upgrades required.
1. Token Send
1.1 Current State
UTXO Model (Utxo.kt) is bare:
data class Utxo(
val txHash: String,
val outputIndex: Int,
val amount: Long, // lovelace only
val address: String,
)
KoiosCardanoClient already receives native assets in the utxo_set.asset_list response but ignores them:
// Current code only extracts lovelace:
val lovelace = utxoJson.optString("value", "0").toLongOrNull() ?: 0L
PaymentEntryState has no asset selection — only amountInput for ADA.
DefaultTransactionBuilder uses only Amount.lovelace():
val tx = Tx()
.payToAddress(recipientAddress, Amount.lovelace(BigInteger.valueOf(amountLovelace)))
1.2 cardano-client-lib 0.7.1 Native Asset API
The library fully supports native assets:
// Amount class supports both ADA and native assets:
Amount.lovelace(BigInteger quantity)
Amount.asset(String policyId, String assetName, BigInteger quantity)
Amount.asset(String policyId, String assetName, long quantity)
// Tx.payToAddress accepts List<Amount> for multi-asset outputs:
tx.payToAddress(recipientAddress, listOf(
Amount.lovelace(BigInteger.valueOf(minUtxoLovelace)),
Amount.asset(policyId, assetName, BigInteger.valueOf(tokenQuantity))
))
The unit format is policyId + assetName (concatenated hex strings).
1.3 Implementation Changes
1.3.1 Enhance UTXO Model
File: wallet/api/src/main/kotlin/.../Utxo.kt
data class Utxo(
val txHash: String,
val outputIndex: Int,
val amount: Long, // lovelace
val address: String,
val assets: List<UtxoAsset>, // NEW: native assets in this UTXO
)
data class UtxoAsset(
val policyId: String,
val assetName: String, // hex-encoded
val quantity: Long,
)
1.3.2 Parse Assets in KoiosCardanoClient
File: wallet/impl/src/main/kotlin/.../cardano/KoiosCardanoClient.kt
// In getUtxos():
val utxos = (0 until utxoSet.length()).map { i ->
val utxoJson = utxoSet.getJSONObject(i)
val lovelace = utxoJson.optString("value", "0").toLongOrNull() ?: 0L
// NEW: Parse asset_list
val assetList = utxoJson.optJSONArray("asset_list") ?: JSONArray()
val assets = (0 until assetList.length()).map { j ->
val asset = assetList.getJSONObject(j)
UtxoAsset(
policyId = asset.getString("policy_id"),
assetName = asset.optString("asset_name", ""),
quantity = asset.optString("quantity", "0").toLongOrNull() ?: 0L,
)
}
Utxo(
txHash = utxoJson.getString("tx_hash"),
outputIndex = utxoJson.getInt("tx_index"),
amount = lovelace,
address = address,
assets = assets, // NEW
)
}
1.3.3 Add Asset Selection to PaymentEntryState
File: wallet/impl/src/main/kotlin/.../payment/PaymentEntryState.kt
data class PaymentEntryState(
// ... existing fields ...
// NEW: Asset selection
val selectedAsset: SelectedAsset?, // null = sending ADA
val availableAssets: List<AssetBalance>, // user's token balances
val tokenAmountInput: String, // quantity when sending token
val parsedTokenAmount: Long?,
val tokenAmountError: String?,
)
sealed interface SelectedAsset {
data object Ada : SelectedAsset
data class Token(
val policyId: String,
val assetName: String,
val displayName: String,
val availableQuantity: Long,
) : SelectedAsset
}
data class AssetBalance(
val policyId: String,
val assetName: String,
val displayName: String,
val quantity: Long,
val imageUrl: String?, // for picker UI
)
1.3.4 Update PaymentEntryPresenter
File: wallet/impl/src/main/kotlin/.../payment/PaymentEntryPresenter.kt
Add new events:
sealed interface PaymentFlowEvents {
// ... existing ...
data class AssetSelected(val asset: SelectedAsset) : PaymentFlowEvents
data class TokenAmountChanged(val amount: String) : PaymentFlowEvents
}
Add state management for asset selection:
var selectedAsset by remember { mutableStateOf<SelectedAsset>(SelectedAsset.Ada) }
var tokenAmountInput by remember { mutableStateOf("") }
var availableAssets by remember { mutableStateOf<List<AssetBalance>>(emptyList()) }
// Load available assets when wallet loads
LaunchedEffect(senderAddress) {
senderAddress?.let { addr ->
cardanoClient.getAddressAssets(addr).onSuccess { assets ->
availableAssets = assets.map { asset ->
AssetBalance(
policyId = asset.policyId,
assetName = asset.assetName,
displayName = asset.name,
quantity = asset.quantity,
imageUrl = null, // fetch metadata separately if needed
)
}
}
}
}
// Update canContinue logic to handle token sends
val canContinue = when (selectedAsset) {
is SelectedAsset.Ada -> {
parsedAmountLovelace != null &&
parsedAmountLovelace >= MIN_AMOUNT_LOVELACE &&
isValidRecipient
}
is SelectedAsset.Token -> {
val token = selectedAsset as SelectedAsset.Token
parsedTokenAmount != null &&
parsedTokenAmount > 0 &&
parsedTokenAmount <= token.availableQuantity &&
isValidRecipient
}
}
1.3.5 Update DefaultTransactionBuilder
File: wallet/impl/src/main/kotlin/.../cardano/DefaultTransactionBuilder.kt
// Update PaymentRequest to include optional asset
data class PaymentRequest(
val sessionId: SessionId,
val fromAddress: String,
val toAddress: String,
val amountLovelace: Long,
// NEW fields:
val assetPolicyId: String? = null,
val assetName: String? = null,
val assetQuantity: Long? = null,
)
// Update buildTransaction():
private fun buildTransaction(
senderAddress: String,
recipientAddress: String,
amountLovelace: Long,
mnemonic: String,
assetPolicyId: String? = null,
assetName: String? = null,
assetQuantity: Long? = null,
): SignedTransaction {
val account = Account(CardanoNetworkConfig.getNetwork(), mnemonic)
val amounts = mutableListOf<Amount>()
// Always include ADA (min UTXO for token sends, full amount for ADA sends)
amounts.add(Amount.lovelace(BigInteger.valueOf(amountLovelace)))
// Add native asset if sending tokens
if (assetPolicyId != null && assetName != null && assetQuantity != null) {
amounts.add(Amount.asset(assetPolicyId, assetName, BigInteger.valueOf(assetQuantity)))
}
val tx = Tx()
.payToAddress(recipientAddress, amounts)
.from(senderAddress)
val quickTxBuilder = QuickTxBuilder(backendService)
val signedTx = quickTxBuilder
.compose(tx)
.withSigner(SignerProviders.signerFrom(account))
.buildAndSign()
// ... rest unchanged ...
}
1.3.6 Update Matrix Payment Event
File: wallet/impl/src/main/kotlin/.../payment/DefaultPaymentEventSender.kt
@Serializable
data class PaymentEventData(
val amountLovelace: Long,
val toAddress: String,
val fromAddress: String,
val txHash: String?,
val status: String,
val network: String,
// NEW: Native asset fields (null for ADA-only payments)
val assetPolicyId: String? = null,
val assetName: String? = null, // hex
val assetDisplayName: String? = null, // human-readable
val assetQuantity: Long? = null,
)
1.3.7 UI: Asset Picker in PaymentEntryView
Add a dropdown/picker above the amount field:
- Default: "ADA" selected
- Expandable list showing: ADA + all available tokens
- When token selected, show token amount field instead of ADA amount
- Display available balance for selected asset
1.3.8 Update Timeline Payment Card
File: wallet/impl/src/main/kotlin/.../timeline/TimelineItemPaymentView.kt
Show "10 HOSKY" instead of "10 ADA" when displaying token payments.
1.4 Min UTXO Handling for Token Sends
When sending only tokens (no ADA amount specified), include the minimum UTXO (~1.5 ADA) automatically:
val minUtxoForTokens = 1_500_000L // ~1.5 ADA for token output
val lovelaceAmount = if (isTokenOnlySend) minUtxoForTokens else requestedLovelace
2. ADA Handle Resolution
2.1 Current State
RecipientResolutionState handles Matrix user lookup but not ADA Handles.
Koios API confirmed working:
# $cobb resolves to:
curl -X POST "https://api.koios.rest/api/v1/asset_addresses" \
-d '{"_asset_policy":"f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a","_asset_name":"636f6262"}'
# Returns:
[{"payment_address":"addr1q85l4twecj9erkh49uv73w0p5ywsu7q7su7hpk3pangzd4x4n5czgvze3u5zflj9v2a4ttmdhtfr2rfdx0g4pp6p0tzs0h79mz"}]
2.2 Implementation Changes
2.2.1 Add Handle Resolution to CardanoClient
File: wallet/api/src/main/kotlin/.../CardanoClient.kt
interface CardanoClient {
// ... existing methods ...
/**
* Resolve an ADA Handle to a Cardano address.
* @param handle Handle name WITHOUT the $ prefix (e.g., "cobb" not "$cobb")
* @return Bech32 address or null if handle doesn't exist
*/
suspend fun resolveHandle(handle: String): Result<String?>
}
2.2.2 Implement in KoiosCardanoClient
File: wallet/impl/src/main/kotlin/.../cardano/KoiosCardanoClient.kt
companion object {
// ADA Handle policy ID (mainnet - same for testnet handles)
private const val ADA_HANDLE_POLICY_ID = "f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a"
}
override suspend fun resolveHandle(handle: String): Result<String?> =
withRetry("resolveHandle($handle)") {
withContext(Dispatchers.IO) {
throttleRequest()
// Convert handle to hex (ASCII to hex)
val handleHex = handle.lowercase().toByteArray().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()
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() ?: ""
if (!response.isSuccessful) {
return@withContext Result.failure(parseHttpError(response.code, responseBody))
}
val jsonArray = JSONArray(responseBody)
if (jsonArray.length() == 0) {
return@withContext Result.success(null) // Handle not found
}
val address = jsonArray.getJSONObject(0).getString("payment_address")
Result.success(address)
}
}
2.2.3 Update PaymentEntryPresenter
File: wallet/impl/src/main/kotlin/.../payment/PaymentEntryPresenter.kt
Add $ prefix detection alongside Matrix user and Cardano address detection:
companion object {
// ... existing ...
private val HANDLE_REGEX = "^\\\$[a-zA-Z0-9_.-]+$".toRegex() // $handle format
}
// In LaunchedEffect(recipientInput):
LaunchedEffect(recipientInput) {
val isHandle = HANDLE_REGEX.matches(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
}
isHandle -> {
// NEW: ADA Handle resolution
val handleName = recipientInput.removePrefix("$")
recipientResolutionState = RecipientResolutionState.Resolving(recipientInput)
cardanoClient.resolveHandle(handleName)
.onSuccess { address ->
if (address != null) {
recipientResolutionState = RecipientResolutionState.Found(
matrixUserId = recipientInput, // reuse for display
address = address
)
resolvedCardanoAddress = address
} else {
recipientResolutionState = RecipientResolutionState.Error(
"Handle $recipientInput not found"
)
}
}
.onFailure { e ->
recipientResolutionState = RecipientResolutionState.Error(
"Failed to resolve handle: ${e.message}"
)
}
}
isMatrixUser -> {
// ... existing Matrix lookup ...
}
else -> {
recipientResolutionState = RecipientResolutionState.NotNeeded
resolvedCardanoAddress = null
}
}
}
2.2.4 Update RecipientResolutionState
File: wallet/impl/src/main/kotlin/.../payment/PaymentEntryState.kt
sealed interface RecipientResolutionState {
// ... existing states ...
/** Found address via ADA Handle resolution */
data class HandleResolved(
val handle: String,
val address: String,
) : RecipientResolutionState
}
2.2.5 Matrix Profile Handle Storage (Optional)
Store handle alongside address in Matrix account data:
File: wallet/impl/src/main/kotlin/.../address/DefaultCardanoAddressService.kt
@Serializable
private data class CardanoAddressData(
val address: String,
val handle: String? = null, // NEW: optional handle
)
User can opt to publish their handle so others can send to $handle even without knowing their Matrix ID.
2.2.6 Caching
- In-memory cache:
Map<String, CachedHandle>with 1-hour TTL - Scope: Per-session (clears when app restarts)
- Why short TTL: Handles can be transferred to new addresses
private data class CachedHandle(
val address: String?,
val timestamp: Long,
)
private val handleCache = mutableMapOf<String, CachedHandle>()
private const val CACHE_TTL_MS = 60 * 60 * 1000L // 1 hour
3. NFT Display
3.1 Current State
AssetsTabView shows basic text cards:
- Asset name (decoded from hex or raw)
- Truncated policy ID
- Quantity
No thumbnails — no image loading.
Koios returns CIP-25 metadata via asset_info endpoint:
{
"minting_tx_metadata": {
"721": {
"<policy_id>": {
"<asset_name>": {
"name": "SpaceBud #1000",
"image": "ipfs://QmZvDBddCrmq1Jv6KXiSgirDUZYk1xL67ue7YS636T1PLq",
"traits": ["Chestplate", "Belt"]
}
}
}
}
}
Coil 3.4.0 already in project (libs.versions.toml confirms).
3.2 Implementation Changes
3.2.1 Enhance NativeAsset Model
File: wallet/api/src/main/kotlin/.../NativeAsset.kt
data class NativeAsset(
val policyId: String,
val assetName: String, // hex
val quantity: Long,
val displayName: String?,
val fingerprint: String?,
// NEW metadata fields:
val imageUrl: String?, // resolved IPFS/HTTPS URL
val decimals: Int?, // for fungible tokens
val ticker: String?, // e.g., "HOSKY"
val description: String?,
val isNft: Boolean, // true if quantity == 1 and has CIP-25 metadata
)
3.2.2 Add Asset Metadata Fetching to CardanoClient
File: wallet/api/src/main/kotlin/.../CardanoClient.kt
interface CardanoClient {
// ... existing ...
/**
* Get metadata for specific assets (CIP-25/CIP-68).
* @param assets List of (policyId, assetName) pairs
* @return Map of fingerprint -> AssetMetadata
*/
suspend fun getAssetMetadata(assets: List<Pair<String, String>>): Result<Map<String, AssetMetadata>>
}
data class AssetMetadata(
val name: String?,
val image: String?, // raw IPFS or HTTPS URL
val description: String?,
val ticker: String?,
val decimals: Int?,
)
3.2.3 Implement in KoiosCardanoClient
File: wallet/impl/src/main/kotlin/.../cardano/KoiosCardanoClient.kt
companion object {
// IPFS gateways (in preference order)
private val IPFS_GATEWAYS = listOf(
"https://ipfs.io/ipfs/",
"https://cloudflare-ipfs.com/ipfs/",
"https://dweb.link/ipfs/",
)
}
override suspend fun getAssetMetadata(
assets: List<Pair<String, String>>
): Result<Map<String, AssetMetadata>> =
withRetry("getAssetMetadata") {
withContext(Dispatchers.IO) {
throttleRequest()
val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}asset_info"
val assetList = JSONArray()
assets.forEach { (policyId, assetName) ->
assetList.put(JSONArray().put(policyId).put(assetName))
}
val body = JSONObject().apply {
put("_asset_list", assetList)
}.toString()
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() ?: ""
if (!response.isSuccessful) {
return@withContext Result.failure(parseHttpError(response.code, responseBody))
}
val jsonArray = JSONArray(responseBody)
val metadataMap = mutableMapOf<String, AssetMetadata>()
for (i in 0 until jsonArray.length()) {
val assetJson = jsonArray.getJSONObject(i)
val fingerprint = assetJson.optString("fingerprint", "")
// Try CIP-25 metadata first (NFTs)
val mintingMeta = assetJson.optJSONObject("minting_tx_metadata")
val cip25 = mintingMeta?.optJSONObject("721")
// Try token registry metadata (fungible tokens)
val registryMeta = assetJson.optJSONObject("token_registry_metadata")
val metadata = when {
cip25 != null -> parseCip25Metadata(cip25, assetJson)
registryMeta != null -> parseRegistryMetadata(registryMeta)
else -> null
}
if (metadata != null && fingerprint.isNotEmpty()) {
metadataMap[fingerprint] = metadata
}
}
Result.success(metadataMap)
}
}
private fun parseCip25Metadata(cip25: JSONObject, assetJson: JSONObject): AssetMetadata? {
val policyId = assetJson.getString("policy_id")
val assetNameAscii = assetJson.optString("asset_name_ascii", "")
val policyObj = cip25.optJSONObject(policyId) ?: return null
val assetObj = policyObj.optJSONObject(assetNameAscii) ?: return null
val rawImage = assetObj.optString("image", "")
val imageUrl = resolveIpfsUrl(rawImage)
return AssetMetadata(
name = assetObj.optString("name", null),
image = imageUrl,
description = assetObj.optString("description", null),
ticker = null,
decimals = null,
)
}
private fun parseRegistryMetadata(registry: JSONObject): AssetMetadata {
val rawLogo = registry.optString("logo", "")
// Registry logos are usually base64 or direct URLs
val imageUrl = if (rawLogo.startsWith("ipfs://")) {
resolveIpfsUrl(rawLogo)
} else if (rawLogo.startsWith("data:") || rawLogo.startsWith("http")) {
rawLogo
} else {
null
}
return AssetMetadata(
name = registry.optString("name", null),
image = imageUrl,
description = registry.optString("description", null),
ticker = registry.optString("ticker", null),
decimals = registry.optInt("decimals", 0),
)
}
private fun resolveIpfsUrl(raw: String): String? {
if (raw.isBlank()) return null
return when {
raw.startsWith("ipfs://") -> {
val cid = raw.removePrefix("ipfs://")
IPFS_GATEWAYS.first() + cid // Use first gateway
}
raw.startsWith("http") -> raw
else -> null
}
}
3.2.4 Update WalletPanelPresenter
Fetch metadata when loading assets:
// After loading assets
LaunchedEffect(assets) {
if (assets.isNotEmpty()) {
val assetPairs = assets.map { it.policyId to it.assetName }
cardanoClient.getAssetMetadata(assetPairs).onSuccess { metadata ->
// Merge metadata into assets
enrichedAssets = assets.map { asset ->
val meta = metadata[asset.fingerprint]
asset.copy(
displayName = meta?.name ?: asset.displayName,
imageUrl = meta?.image,
decimals = meta?.decimals,
ticker = meta?.ticker,
isNft = asset.quantity == 1L && meta?.image != null,
)
}
}
}
}
3.2.5 Update AssetsTabView with Image Thumbnails
File: wallet/impl/src/main/kotlin/.../panel/tabs/AssetsTabView.kt
import coil3.compose.AsyncImage
import coil3.request.crossfade
@Composable
private fun AssetCard(
asset: NativeAsset,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// NEW: Thumbnail
if (asset.imageUrl != null) {
AsyncImage(
model = asset.imageUrl,
contentDescription = asset.name,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop,
)
} else {
// Placeholder icon for tokens without images
Box(
modifier = Modifier
.size(48.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(8.dp)
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = CompoundIcons.Files(),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = asset.name,
style = MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Medium,
),
)
Text(
text = asset.truncatedPolicyId,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Quantity (with decimals if fungible token)
val displayQuantity = if (asset.decimals != null && asset.decimals > 0) {
val divisor = 10.0.pow(asset.decimals)
"%.${asset.decimals}f".format(asset.quantity / divisor)
} else {
asset.quantity.toString()
}
Text(
text = displayQuantity,
style = MaterialTheme.typography.titleMedium,
)
}
}
}
3.2.6 Add Coil Dependency to Wallet Module
File: wallet/impl/build.gradle.kts
dependencies {
// ... existing ...
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
}
4. Complexity Estimates
| Feature | Complexity | Reasoning |
|---|---|---|
| Token Send | Medium | UTXO model change, PaymentEntryState changes, TxBuilder update, UI picker. Most code paths touched but straightforward. |
| ADA Handle Resolution | Easy | Single Koios API call, simple hex encoding, integrate into existing resolution flow. ~2-3 hours. |
| NFT Display | Easy-Medium | Metadata fetching is straightforward. Coil integration is simple. CIP-25 parsing needs careful handling of edge cases. |
Total estimate: 2-3 days of focused implementation.
5. Blockers & Gotchas
5.1 Token Send
-
Min UTXO for token outputs: Cardano requires ~1.5 ADA minimum in outputs containing tokens. Must calculate and add automatically.
-
UTXO selection for tokens: Need to ensure selected UTXOs contain the token being sent. Current coin selection may need adjustment.
-
Token quantity validation: Prevent sending more tokens than available in UTXOs.
5.2 ADA Handle Resolution
-
Case sensitivity: Handles are case-insensitive but stored lowercase. Always normalize to lowercase before hex encoding.
-
Handle characters: Valid characters are
[a-z0-9_.-]. Validate before API call. -
Virtual handles: Some handles are "virtual" (subhandles like
@nameunder a root handle). These resolve differently. For Phase 6, only support root handles ($name).
5.3 NFT Display
-
IPFS gateway reliability: Use fallback gateways if primary fails. Consider gateway.pinata.cloud if others are slow.
-
Large images: NFT images can be large. Use Coil's
size()to request appropriate dimensions. -
CIP-25 vs CIP-68: CIP-68 NFTs store metadata differently (on-chain datum). Koios
cip68_metadatafield handles this — check both paths. -
NSFW content: No filtering currently. May want to add in future.
6. Testing Checklist
Token Send
- Send ADA only (existing functionality preserved)
- Send token only (auto-adds min UTXO)
- Send ADA + token in one tx
- Insufficient token balance error
- Token appears in recipient's Assets tab
- Payment card shows token amount
ADA Handle Resolution
$cobbresolves to correct address (mainnet)- Invalid handle shows error
- Non-existent handle shows "not found"
- Handle caching works (no duplicate API calls)
- Can send to resolved handle address
NFT Display
- NFT thumbnails load in Assets tab
- IPFS images resolve correctly
- Placeholder shown for tokens without images
- Fungible tokens show proper decimal formatting
- Large NFT collections load without OOM
7. Files to Modify
API Module (wallet/api/)
NativeAsset.kt— add metadata fieldsUtxo.kt— addassetslistCardanoClient.kt— addresolveHandle(),getAssetMetadata()PaymentRequest.kt— add asset fields
Impl Module (wallet/impl/)
KoiosCardanoClient.kt— implement handle resolution, asset metadataDefaultTransactionBuilder.kt— support multi-asset transactionsDefaultPaymentEventSender.kt— add asset fields to eventPaymentEntryPresenter.kt— asset selection, handle resolutionPaymentEntryState.kt— asset selection statePaymentEntryView.kt— asset picker UIWalletPanelPresenter.kt— fetch asset metadataAssetsTabView.kt— NFT thumbnailsTimelineItemPaymentView.kt— show token payments
Test Module (wallet/test/)
FakeCardanoClient.kt— add mock implementationsKoiosCardanoClientTest.kt— test handle resolution, metadata
Plan ready for implementation. No code written — implementation agent takes it from here.