# 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: ```kotlin 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**: ```kotlin // 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()`: ```kotlin 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: ```kotlin // 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 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` ```kotlin data class Utxo( val txHash: String, val outputIndex: Int, val amount: Long, // lovelace val address: String, val assets: List, // 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` ```kotlin // 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` ```kotlin data class PaymentEntryState( // ... existing fields ... // NEW: Asset selection val selectedAsset: SelectedAsset?, // null = sending ADA val availableAssets: List, // 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: ```kotlin sealed interface PaymentFlowEvents { // ... existing ... data class AssetSelected(val asset: SelectedAsset) : PaymentFlowEvents data class TokenAmountChanged(val amount: String) : PaymentFlowEvents } ``` Add state management for asset selection: ```kotlin var selectedAsset by remember { mutableStateOf(SelectedAsset.Ada) } var tokenAmountInput by remember { mutableStateOf("") } var availableAssets by remember { mutableStateOf>(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` ```kotlin // 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() // 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` ```kotlin @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: ```kotlin 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:** ```bash # $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` ```kotlin 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 } ``` #### 2.2.2 Implement in KoiosCardanoClient **File:** `wallet/impl/src/main/kotlin/.../cardano/KoiosCardanoClient.kt` ```kotlin 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 = 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: ```kotlin 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` ```kotlin 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` ```kotlin @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` with 1-hour TTL - **Scope**: Per-session (clears when app restarts) - **Why short TTL**: Handles can be transferred to new addresses ```kotlin private data class CachedHandle( val address: String?, val timestamp: Long, ) private val handleCache = mutableMapOf() 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: ```json { "minting_tx_metadata": { "721": { "": { "": { "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` ```kotlin 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` ```kotlin 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>): Result> } 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` ```kotlin 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> ): Result> = 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() 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: ```kotlin // 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` ```kotlin 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` ```kotlin 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.*