element-x-ada/docs/build-logs/phase6-plan.md

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:

  1. Token Send — Send native assets (Cardano tokens) alongside or instead of ADA
  2. ADA Handle Resolution — Resolve $handle to Cardano addresses
  3. 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

  1. Min UTXO for token outputs: Cardano requires ~1.5 ADA minimum in outputs containing tokens. Must calculate and add automatically.

  2. UTXO selection for tokens: Need to ensure selected UTXOs contain the token being sent. Current coin selection may need adjustment.

  3. Token quantity validation: Prevent sending more tokens than available in UTXOs.

5.2 ADA Handle Resolution

  1. Case sensitivity: Handles are case-insensitive but stored lowercase. Always normalize to lowercase before hex encoding.

  2. Handle characters: Valid characters are [a-z0-9_.-]. Validate before API call.

  3. Virtual handles: Some handles are "virtual" (subhandles like @name under a root handle). These resolve differently. For Phase 6, only support root handles ($name).

5.3 NFT Display

  1. IPFS gateway reliability: Use fallback gateways if primary fails. Consider gateway.pinata.cloud if others are slow.

  2. Large images: NFT images can be large. Use Coil's size() to request appropriate dimensions.

  3. CIP-25 vs CIP-68: CIP-68 NFTs store metadata differently (on-chain datum). Koios cip68_metadata field handles this — check both paths.

  4. 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

  • $cobb resolves 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 fields
  • Utxo.kt — add assets list
  • CardanoClient.kt — add resolveHandle(), getAssetMetadata()
  • PaymentRequest.kt — add asset fields

Impl Module (wallet/impl/)

  • KoiosCardanoClient.kt — implement handle resolution, asset metadata
  • DefaultTransactionBuilder.kt — support multi-asset transactions
  • DefaultPaymentEventSender.kt — add asset fields to event
  • PaymentEntryPresenter.kt — asset selection, handle resolution
  • PaymentEntryState.kt — asset selection state
  • PaymentEntryView.kt — asset picker UI
  • WalletPanelPresenter.kt — fetch asset metadata
  • AssetsTabView.kt — NFT thumbnails
  • TimelineItemPaymentView.kt — show token payments

Test Module (wallet/test/)

  • FakeCardanoClient.kt — add mock implementations
  • KoiosCardanoClientTest.kt — test handle resolution, metadata

Plan ready for implementation. No code written — implementation agent takes it from here.