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

922 lines
29 KiB
Markdown

# 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<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`
```kotlin
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`
```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<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:
```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>(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`
```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<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`
```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<String?>
}
```
#### 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<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:
```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<String, CachedHandle>` 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<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:
```json
{
"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`
```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<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`
```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<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:
```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.*