922 lines
29 KiB
Markdown
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.*
|