From a57fd790989eaecf741afaa6f740537525bea491 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sun, 29 Mar 2026 10:58:17 -0700 Subject: [PATCH] feat(wallet): token send support with asset picker - Add UtxoAsset model for native assets in UTXOs - Update KoiosCardanoClient.getUtxos() to parse asset_list - Add asset fields to PaymentRequest (policyId, name, quantity) - DefaultTransactionBuilder: multi-asset tx with Amount.asset() - Min UTXO: always include 1.5 ADA with token sends (protocol req) - PaymentEntryPresenter: load available assets, handle selection - PaymentEntryView: asset picker dropdown when tokens available - PaymentConfirmation: show token name/quantity instead of ADA - PaymentProgress: displayAmount field for token sends - Wire asset data through entire nav flow (FlowNode/Nodes) - Updated NativeAsset with metadata fields for NFT prep --- .../features/wallet/api/NativeAsset.kt | 30 ++- .../features/wallet/api/PaymentRequest.kt | 17 +- .../android/features/wallet/api/Utxo.kt | 15 ++ .../features/wallet/impl/PaymentFlowNode.kt | 43 +++- .../impl/cardano/DefaultTransactionBuilder.kt | 53 ++++- .../wallet/impl/cardano/KoiosCardanoClient.kt | 17 +- .../impl/payment/PaymentConfirmationNode.kt | 17 +- .../payment/PaymentConfirmationPresenter.kt | 24 +- .../impl/payment/PaymentConfirmationState.kt | 16 ++ .../impl/payment/PaymentConfirmationView.kt | 57 ++++- .../wallet/impl/payment/PaymentEntryNode.kt | 36 ++- .../impl/payment/PaymentEntryPresenter.kt | 92 +++++++- .../wallet/impl/payment/PaymentEntryState.kt | 24 ++ .../wallet/impl/payment/PaymentEntryView.kt | 221 ++++++++++++------ .../wallet/impl/payment/PaymentFlowEvents.kt | 6 + .../impl/payment/PaymentProgressNode.kt | 8 + .../impl/payment/PaymentProgressPresenter.kt | 37 ++- .../impl/payment/PaymentProgressState.kt | 8 + .../impl/payment/PaymentProgressView.kt | 18 +- .../features/wallet/test/FakeCardanoClient.kt | 9 +- 20 files changed, 648 insertions(+), 100 deletions(-) diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/NativeAsset.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/NativeAsset.kt index b9b132c2f8..573da19b20 100644 --- a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/NativeAsset.kt +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/NativeAsset.kt @@ -14,6 +14,11 @@ package io.element.android.features.wallet.api * @property quantity The amount of this asset * @property displayName Human-readable name if available * @property fingerprint The asset fingerprint (CIP-14) + * @property imageUrl Resolved image URL (IPFS gateway or HTTPS) for NFTs + * @property decimals Decimal places for fungible tokens (null for NFTs) + * @property ticker Token ticker symbol (e.g., "HOSKY") + * @property description Token/NFT description + * @property isNft True if this is likely an NFT (quantity == 1 with image metadata) */ data class NativeAsset( val policyId: String, @@ -21,6 +26,11 @@ data class NativeAsset( val quantity: Long, val displayName: String?, val fingerprint: String?, + val imageUrl: String? = null, + val decimals: Int? = null, + val ticker: String? = null, + val description: String? = null, + val isNft: Boolean = false, ) { /** * Truncated policy ID for display. @@ -36,7 +46,7 @@ data class NativeAsset( * Display name, falling back to truncated asset name. */ val name: String - get() = displayName ?: assetName.takeIf { it.isNotEmpty() }?.let { + get() = displayName ?: ticker ?: assetName.takeIf { it.isNotEmpty() }?.let { // Try to decode hex to ASCII if it looks printable try { val decoded = it.chunked(2).map { hex -> hex.toInt(16).toChar() }.joinToString("") @@ -45,4 +55,22 @@ data class NativeAsset( it } } ?: "Unknown" + + /** + * Unit string for this asset (concatenated policyId + assetName). + */ + val unit: String + get() = "$policyId$assetName" + + /** + * Format quantity with decimals for display. + */ + fun formatQuantity(): String { + return if (decimals != null && decimals > 0) { + val divisor = Math.pow(10.0, decimals.toDouble()) + String.format("%.${decimals}f", quantity / divisor).trimEnd('0').trimEnd('.') + } else { + quantity.toString() + } + } } diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/PaymentRequest.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/PaymentRequest.kt index f9efa37c70..55ac2ecc12 100644 --- a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/PaymentRequest.kt +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/PaymentRequest.kt @@ -13,12 +13,25 @@ import io.element.android.libraries.matrix.api.core.SessionId * * @property fromAddress The sender's Cardano address (Bech32) * @property toAddress The recipient's Cardano address (Bech32) - * @property amountLovelace The amount to send in lovelace (1 ADA = 1,000,000 lovelace) + * @property amountLovelace The amount of ADA to send in lovelace (1 ADA = 1,000,000 lovelace). + * For token-only sends, this should be the minimum UTXO (~1.5 ADA). * @property sessionId The Matrix session ID for key retrieval + * @property assetPolicyId Policy ID of the native asset to send (null for ADA-only) + * @property assetName Asset name in hex (null for ADA-only) + * @property assetQuantity Quantity of the native asset to send (null for ADA-only) */ data class PaymentRequest( val fromAddress: String, val toAddress: String, val amountLovelace: Long, val sessionId: SessionId, -) + val assetPolicyId: String? = null, + val assetName: String? = null, + val assetQuantity: Long? = null, +) { + /** + * True if this request includes a native asset (token) send. + */ + val hasAsset: Boolean + get() = assetPolicyId != null && assetName != null && assetQuantity != null && assetQuantity > 0 +} diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/Utxo.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/Utxo.kt index 547765dbe8..b54d1b4759 100644 --- a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/Utxo.kt +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/Utxo.kt @@ -13,10 +13,25 @@ package io.element.android.features.wallet.api * @property outputIndex The index of this output within the transaction. * @property amount The amount in lovelace (1 ADA = 1,000,000 lovelace). * @property address The address holding this UTxO. + * @property assets Native assets (tokens) contained in this UTxO. */ data class Utxo( val txHash: String, val outputIndex: Int, val amount: Long, val address: String, + val assets: List = emptyList(), +) + +/** + * Represents a native asset within a UTxO. + * + * @property policyId The minting policy ID (56 hex chars). + * @property assetName The asset name (hex-encoded). + * @property quantity The amount of this asset in the UTxO. + */ +data class UtxoAsset( + val policyId: String, + val assetName: String, + val quantity: Long, ) diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/PaymentFlowNode.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/PaymentFlowNode.kt index 2c7f164b5f..260da6da4d 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/PaymentFlowNode.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/PaymentFlowNode.kt @@ -81,6 +81,10 @@ class PaymentFlowNode( data class Confirmation( val recipientAddress: String, val amountLovelace: Lovelace, + val assetPolicyId: String?, + val assetName: String?, + val assetQuantity: Long?, + val assetDisplayName: String?, ) : NavTarget @Parcelize @@ -88,6 +92,10 @@ class PaymentFlowNode( val recipientAddress: String, val amountLovelace: Lovelace, val roomId: RoomId, + val assetPolicyId: String?, + val assetName: String?, + val assetQuantity: Long?, + val assetDisplayName: String?, ) : NavTarget } @@ -99,10 +107,21 @@ class PaymentFlowNode( parsedCommand = navTarget.parsedCommand, ) val nodeCallback = object : PaymentEntryNode.Callback { - override fun onContinue(recipientAddress: String, amountLovelace: Long) { + override fun onContinue( + recipientAddress: String, + amountLovelace: Long, + assetPolicyId: String?, + assetName: String?, + assetQuantity: Long?, + assetDisplayName: String?, + ) { backstack.push(NavTarget.Confirmation( recipientAddress = recipientAddress, amountLovelace = amountLovelace, + assetPolicyId = assetPolicyId, + assetName = assetName, + assetQuantity = assetQuantity, + assetDisplayName = assetDisplayName, )) } @@ -122,6 +141,10 @@ class PaymentFlowNode( val nodeInputs = PaymentConfirmationNode.Inputs( recipientAddress = navTarget.recipientAddress, amountLovelace = navTarget.amountLovelace, + assetPolicyId = navTarget.assetPolicyId, + assetName = navTarget.assetName, + assetQuantity = navTarget.assetQuantity, + assetDisplayName = navTarget.assetDisplayName, ) val nodeCallback = object : PaymentConfirmationNode.Callback { override fun onConfirmed() { @@ -129,6 +152,10 @@ class PaymentFlowNode( recipientAddress = navTarget.recipientAddress, amountLovelace = navTarget.amountLovelace, roomId = inputs.roomId, + assetPolicyId = navTarget.assetPolicyId, + assetName = navTarget.assetName, + assetQuantity = navTarget.assetQuantity, + assetDisplayName = navTarget.assetDisplayName, )) } @@ -144,6 +171,10 @@ class PaymentFlowNode( recipientAddress = navTarget.recipientAddress, amountLovelace = navTarget.amountLovelace, roomId = navTarget.roomId, + assetPolicyId = navTarget.assetPolicyId, + assetName = navTarget.assetName, + assetQuantity = navTarget.assetQuantity, + assetDisplayName = navTarget.assetDisplayName, ) val nodeCallback = object : PaymentProgressNode.Callback { override fun onPaymentComplete(txHash: String?) { @@ -181,10 +212,14 @@ private fun initialElementFromInputs(inputs: PaymentFlowNode.Inputs): PaymentFlo // Check if we can skip to confirmation val parsedCommand = inputs.parsedCommand if (parsedCommand is ParsedPayCommand.WithAddressRecipient) { - // Have both amount and address - go directly to confirmation + // Have both amount and address - go directly to confirmation (ADA only) return PaymentFlowNode.NavTarget.Confirmation( recipientAddress = parsedCommand.address, amountLovelace = parsedCommand.amount, + assetPolicyId = null, + assetName = null, + assetQuantity = null, + assetDisplayName = null, ) } @@ -193,6 +228,10 @@ private fun initialElementFromInputs(inputs: PaymentFlowNode.Inputs): PaymentFlo return PaymentFlowNode.NavTarget.Confirmation( recipientAddress = inputs.recipientAddress, amountLovelace = inputs.amountLovelace, + assetPolicyId = null, + assetName = null, + assetQuantity = null, + assetDisplayName = null, ) } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/DefaultTransactionBuilder.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/DefaultTransactionBuilder.kt index 01f7b350dd..2ee165112c 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/DefaultTransactionBuilder.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/DefaultTransactionBuilder.kt @@ -40,6 +40,8 @@ class DefaultTransactionBuilder @Inject constructor( companion object { private const val TAG = "TransactionBuilder" const val MIN_UTXO_LOVELACE = 1_000_000L + // Minimum ADA to include with token sends (protocol requirement) + const val MIN_TOKEN_UTXO_LOVELACE = 1_500_000L private const val ROUGH_FEE_ESTIMATE = 200_000L } @@ -50,12 +52,22 @@ class DefaultTransactionBuilder @Inject constructor( override suspend fun buildAndSign(request: PaymentRequest): Result = withContext(Dispatchers.IO) { Timber.tag(TAG).d("Building transaction: ${request.amountLovelace} lovelace to ${request.toAddress.take(20)}...") + if (request.hasAsset) { + Timber.tag(TAG).d("Including asset: ${request.assetPolicyId?.take(16)}... qty=${request.assetQuantity}") + } runCatching { validateAddress(request.fromAddress, "sender") validateAddress(request.toAddress, "recipient") - if (request.amountLovelace < MIN_UTXO_LOVELACE) { + // For token sends, enforce minimum ADA + val effectiveLovelace = if (request.hasAsset) { + maxOf(request.amountLovelace, MIN_TOKEN_UTXO_LOVELACE) + } else { + request.amountLovelace + } + + if (!request.hasAsset && effectiveLovelace < MIN_UTXO_LOVELACE) { throw CardanoException.ApiException( message = "Amount too small: minimum is 1 ADA (1,000,000 lovelace)", response = "MIN_UTXO_VIOLATION" @@ -65,13 +77,13 @@ class DefaultTransactionBuilder @Inject constructor( val utxos = cardanoClient.getUtxos(request.fromAddress).getOrThrow() if (utxos.isEmpty()) { throw CardanoException.InsufficientFundsException( - required = request.amountLovelace, + required = effectiveLovelace, available = 0L ) } val totalAvailable = utxos.sumOf { it.amount } - val estimatedRequired = request.amountLovelace + ROUGH_FEE_ESTIMATE + val estimatedRequired = effectiveLovelace + ROUGH_FEE_ESTIMATE if (totalAvailable < estimatedRequired) { throw CardanoException.InsufficientFundsException( @@ -80,6 +92,20 @@ class DefaultTransactionBuilder @Inject constructor( ) } + // Validate token balance if sending tokens + if (request.hasAsset) { + val availableTokens = utxos.flatMap { it.assets } + .filter { it.policyId == request.assetPolicyId && it.assetName == request.assetName } + .sumOf { it.quantity } + + if (availableTokens < (request.assetQuantity ?: 0L)) { + throw CardanoException.ApiException( + message = "Insufficient token balance: have $availableTokens, need ${request.assetQuantity}", + response = "INSUFFICIENT_TOKEN_BALANCE" + ) + } + } + Timber.tag(TAG).d("UTXOs: ${utxos.size} totaling $totalAvailable lovelace") val mnemonicWords = keyStorage.getMnemonic(request.sessionId).getOrThrow() @@ -89,8 +115,11 @@ class DefaultTransactionBuilder @Inject constructor( val signedTx = buildTransaction( senderAddress = request.fromAddress, recipientAddress = request.toAddress, - amountLovelace = request.amountLovelace, + amountLovelace = effectiveLovelace, mnemonic = mnemonicString, + assetPolicyId = request.assetPolicyId, + assetName = request.assetName, + assetQuantity = request.assetQuantity, ) Timber.tag(TAG).i("Transaction built: ${signedTx.txHash}, fee: ${signedTx.fee} lovelace") @@ -106,11 +135,25 @@ class DefaultTransactionBuilder @Inject constructor( recipientAddress: String, amountLovelace: Long, mnemonic: String, + assetPolicyId: String? = null, + assetName: String? = null, + assetQuantity: Long? = null, ): SignedTransaction { val account = Account(CardanoNetworkConfig.getNetwork(), mnemonic) + // Build the list of amounts to send + val amounts = mutableListOf() + + // Always include ADA + amounts.add(Amount.lovelace(BigInteger.valueOf(amountLovelace))) + + // Add native asset if specified + if (assetPolicyId != null && assetName != null && assetQuantity != null && assetQuantity > 0) { + amounts.add(Amount.asset(assetPolicyId, assetName, BigInteger.valueOf(assetQuantity))) + } + val tx = Tx() - .payToAddress(recipientAddress, Amount.lovelace(BigInteger.valueOf(amountLovelace))) + .payToAddress(recipientAddress, amounts) .from(senderAddress) val quickTxBuilder = QuickTxBuilder(backendService) diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt index 18f971b2e0..76b5d0d216 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt @@ -17,6 +17,7 @@ import io.element.android.features.wallet.api.ProtocolParameters import io.element.android.features.wallet.api.TxStatus import io.element.android.features.wallet.api.TxSummary import io.element.android.features.wallet.api.Utxo +import io.element.android.features.wallet.api.UtxoAsset import io.element.android.libraries.di.SessionScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -152,15 +153,29 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient { val utxos = (0 until utxoSet.length()).map { i -> val utxoJson = utxoSet.getJSONObject(i) val lovelace = utxoJson.optString("value", "0").toLongOrNull() ?: 0L + + // Parse native assets in this UTXO + 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, ) } - Timber.tag(TAG).d("getUtxos result: ${utxos.size} UTXOs, total=${utxos.sumOf { it.amount }}") + val totalAssets = utxos.flatMap { it.assets }.sumOf { it.quantity } + Timber.tag(TAG).d("getUtxos result: ${utxos.size} UTXOs, total=${utxos.sumOf { it.amount }}, assets=$totalAssets") Result.success(utxos) } } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationNode.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationNode.kt index 7f1e13612f..8ce27b970a 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationNode.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationNode.kt @@ -37,6 +37,10 @@ class PaymentConfirmationNode @AssistedInject constructor( data class Inputs( val recipientAddress: String, val amountLovelace: Lovelace, + val assetPolicyId: String?, + val assetName: String?, + val assetQuantity: Long?, + val assetDisplayName: String?, ) : NodeInputs, Parcelable interface Callback : Plugin { @@ -51,6 +55,10 @@ class PaymentConfirmationNode @AssistedInject constructor( presenterFactory.create( recipientAddress = inputs.recipientAddress, amountLovelace = inputs.amountLovelace, + assetPolicyId = inputs.assetPolicyId, + assetName = inputs.assetName, + assetQuantity = inputs.assetQuantity, + assetDisplayName = inputs.assetDisplayName, ) } @@ -70,10 +78,17 @@ class PaymentConfirmationNode @AssistedInject constructor( return@launch } + // Build auth subtitle based on asset + val subtitle = if (state.isSendingToken && state.assetDisplayName != null) { + "Authenticate to send ${state.tokenQuantityDisplay} ${state.assetDisplayName}" + } else { + "Authenticate to send ${state.amountAda} ADA" + } + val result = biometricAuthenticator.authenticate( activity = activity, title = "Confirm Payment", - subtitle = "Authenticate to send ${state.amountAda} ADA", + subtitle = subtitle, ) when (result) { diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationPresenter.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationPresenter.kt index 57c13357f8..e1f0abffca 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationPresenter.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationPresenter.kt @@ -29,6 +29,10 @@ import io.element.android.libraries.matrix.api.MatrixClient class PaymentConfirmationPresenter @AssistedInject constructor( @Assisted private val recipientAddress: String, @Assisted private val amountLovelace: Lovelace, + @Assisted private val assetPolicyId: String?, + @Assisted private val assetName: String?, + @Assisted private val assetQuantity: Long?, + @Assisted private val assetDisplayName: String?, private val matrixClient: MatrixClient, private val walletManager: CardanoWalletManager, private val cardanoClient: CardanoClient, @@ -36,16 +40,26 @@ class PaymentConfirmationPresenter @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(recipientAddress: String, amountLovelace: Lovelace): PaymentConfirmationPresenter + fun create( + recipientAddress: String, + amountLovelace: Lovelace, + assetPolicyId: String?, + assetName: String?, + assetQuantity: Long?, + assetDisplayName: String?, + ): PaymentConfirmationPresenter } companion object { private const val ESTIMATED_TX_SIZE_BYTES = 350 + // Token transactions are larger + private const val ESTIMATED_TOKEN_TX_SIZE_BYTES = 450 } @Composable override fun present(): PaymentConfirmationState { val sessionId = matrixClient.sessionId + val isSendingToken = assetPolicyId != null && assetQuantity != null var senderAddress by remember { mutableStateOf("") } var senderBalanceLovelace by remember { mutableStateOf(null) } @@ -66,7 +80,8 @@ class PaymentConfirmationPresenter @AssistedInject constructor( } cardanoClient.getProtocolParameters().onSuccess { params -> - val fee = params.minFeeA * ESTIMATED_TX_SIZE_BYTES + params.minFeeB + val txSize = if (isSendingToken) ESTIMATED_TOKEN_TX_SIZE_BYTES else ESTIMATED_TX_SIZE_BYTES + val fee = params.minFeeA * txSize + params.minFeeB estimatedFeeLovelace = fee isFeeLoading = false }.onFailure { @@ -97,6 +112,11 @@ class PaymentConfirmationPresenter @AssistedInject constructor( isTestnet = CardanoNetworkConfig.NETWORK == CardanoNetwork.TESTNET, isFeeLoading = isFeeLoading, feeError = feeError, + isSendingToken = isSendingToken, + assetPolicyId = assetPolicyId, + assetName = assetName, + assetQuantity = assetQuantity, + assetDisplayName = assetDisplayName, eventSink = {}, ) } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationState.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationState.kt index d95aee1ee2..3f89b61a19 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationState.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationState.kt @@ -26,8 +26,24 @@ data class PaymentConfirmationState( val isTestnet: Boolean, val isFeeLoading: Boolean, val feeError: String?, + /** True if sending a native asset (token). */ + val isSendingToken: Boolean, + /** Policy ID of the token being sent. */ + val assetPolicyId: String?, + /** Asset name (hex) of the token being sent. */ + val assetName: String?, + /** Quantity of the token being sent. */ + val assetQuantity: Long?, + /** Human-readable display name of the token. */ + val assetDisplayName: String?, val eventSink: (PaymentFlowEvents) -> Unit, ) { + /** + * Formatted token quantity for display. + */ + val tokenQuantityDisplay: String? + get() = assetQuantity?.toString() + companion object { fun truncateAddress(address: String): String { if (address.length <= 20) return address diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationView.kt index 7e47fbe050..be44116db8 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationView.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentConfirmationView.kt @@ -88,7 +88,18 @@ fun PaymentConfirmationView( ) { if (state.isTestnet) { TestnetWarningCard() } Spacer(modifier = Modifier.height(8.dp)) - AmountCard(amountAda = state.amountAda) + + // Amount card — show token or ADA + if (state.isSendingToken) { + TokenAmountCard( + tokenQuantity = state.tokenQuantityDisplay ?: "?", + tokenName = state.assetDisplayName ?: "Token", + accompanyingAda = state.amountAda, + ) + } else { + AmountCard(amountAda = state.amountAda) + } + TransactionDetailsCard(state) if (state.insufficientFunds) { InsufficientFundsCard(balanceLovelace = state.senderBalanceLovelace, requiredLovelace = state.totalLovelace) @@ -125,16 +136,43 @@ private fun AmountCard(amountAda: String, modifier: Modifier = Modifier) { } } +/** + * Amount card for token sends — shows token quantity prominently with ADA amount below. + */ +@Composable +private fun TokenAmountCard( + tokenQuantity: String, + tokenName: String, + accompanyingAda: String, + modifier: Modifier = Modifier, +) { + Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) { + Column(modifier = Modifier.fillMaxWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text("Amount", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)) + Text("$tokenQuantity $tokenName", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer) + Text("+ $accompanyingAda ADA (min UTXO)", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)) + } + } +} + @Composable private fun TransactionDetailsCard(state: PaymentConfirmationState, modifier: Modifier = Modifier) { Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { DetailRow(label = "To", value = state.recipientAddressDisplay) + + // Show token info if sending a token + if (state.isSendingToken && state.assetDisplayName != null) { + HorizontalDivider() + DetailRow(label = "Token", value = state.assetDisplayName) + DetailRow(label = "Quantity", value = state.tokenQuantityDisplay ?: "?") + } + HorizontalDivider() DetailRow(label = "Network fee", value = if (state.isFeeLoading) null else state.estimatedFeeAda?.let { "~$it ADA" } ?: "Unknown", isLoading = state.isFeeLoading) state.feeError?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) } HorizontalDivider() - DetailRow(label = "Total", value = state.totalAda?.let { "$it ADA" } ?: "—", isBold = true) + DetailRow(label = "Total ADA", value = state.totalAda?.let { "$it ADA" } ?: "—", isBold = true) } } } @@ -168,12 +206,25 @@ internal fun PaymentConfirmationViewPreview(@PreviewParameter(PaymentConfirmatio internal class PaymentConfirmationStateProvider : PreviewParameterProvider { override val values = sequenceOf( + // ADA send PaymentConfirmationState( recipientAddress = "addr_test1qp2fg770ddmqxxduasjsas8rgimrhknmqjn43mj74g7ta2tjt0n5nh4t5xqf6lp5mwfpksj9csjg9s4kgfhvwj7m7dcq9qf7zj", recipientAddressDisplay = "addr_tes...q9qf7zj", amountLovelace = 10_000_000L, amountAda = "10", estimatedFeeLovelace = 180_000L, estimatedFeeAda = "0.18", totalLovelace = 10_180_000L, totalAda = "10.18", senderAddress = "addr_test1q...", senderBalanceLovelace = 100_000_000L, insufficientFunds = false, - isTestnet = true, isFeeLoading = false, feeError = null, eventSink = {}, + isTestnet = true, isFeeLoading = false, feeError = null, + isSendingToken = false, assetPolicyId = null, assetName = null, assetQuantity = null, assetDisplayName = null, + eventSink = {}, + ), + // Token send + PaymentConfirmationState( + recipientAddress = "addr_test1qp2fg770ddmqxxduasjsas8rgimrhknmqjn43mj74g7ta2tjt0n5nh4t5xqf6lp5mwfpksj9csjg9s4kgfhvwj7m7dcq9qf7zj", + recipientAddressDisplay = "addr_tes...q9qf7zj", amountLovelace = 1_500_000L, amountAda = "1.5", + estimatedFeeLovelace = 200_000L, estimatedFeeAda = "0.2", totalLovelace = 1_700_000L, totalAda = "1.7", + senderAddress = "addr_test1q...", senderBalanceLovelace = 100_000_000L, insufficientFunds = false, + isTestnet = true, isFeeLoading = false, feeError = null, + isSendingToken = true, assetPolicyId = "abc123", assetName = "484f534b59", assetQuantity = 1000L, assetDisplayName = "HOSKY", + eventSink = {}, ), ) } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryNode.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryNode.kt index b199386d5f..b78b4cbe98 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryNode.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryNode.kt @@ -15,6 +15,7 @@ import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode +import io.element.android.features.wallet.impl.cardano.DefaultTransactionBuilder import io.element.android.features.wallet.impl.slash.ParsedPayCommand import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.di.SessionScope @@ -36,7 +37,14 @@ class PaymentEntryNode( ) : NodeInputs, Parcelable interface Callback : Plugin { - fun onContinue(recipientAddress: String, amountLovelace: Long) + fun onContinue( + recipientAddress: String, + amountLovelace: Long, + assetPolicyId: String?, + assetName: String?, + assetQuantity: Long?, + assetDisplayName: String?, + ) fun onCancel() fun onOpenWalletSettings() } @@ -60,8 +68,30 @@ class PaymentEntryNode( onContinue = { // Use the resolved Cardano address (from lookup or manual entry) val recipientAddress = state.resolvedAddress ?: return@PaymentEntryView - val amount = state.parsedAmountLovelace ?: return@PaymentEntryView - callback.onContinue(recipientAddress, amount) + + if (state.selectedAsset != null) { + // Token send — use minimum ADA for UTXO, pass token details + val asset = state.selectedAsset + callback.onContinue( + recipientAddress = recipientAddress, + amountLovelace = DefaultTransactionBuilder.MIN_TOKEN_UTXO_LOVELACE, + assetPolicyId = asset.policyId, + assetName = asset.assetName, + assetQuantity = state.parsedTokenAmount, + assetDisplayName = asset.name, + ) + } else { + // ADA-only send + val amount = state.parsedAmountLovelace ?: return@PaymentEntryView + callback.onContinue( + recipientAddress = recipientAddress, + amountLovelace = amount, + assetPolicyId = null, + assetName = null, + assetQuantity = null, + assetDisplayName = null, + ) + } }, onCancel = { callback.onCancel() diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryPresenter.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryPresenter.kt index 465651643c..88fe4eeb98 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryPresenter.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryPresenter.kt @@ -17,6 +17,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.features.wallet.api.CardanoClient +import io.element.android.features.wallet.api.NativeAsset import io.element.android.features.wallet.api.address.CardanoAddressService import io.element.android.features.wallet.impl.cardano.CardanoNetwork import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig @@ -96,6 +97,11 @@ class PaymentEntryPresenter @AssistedInject constructor( recipientError = null, manualAddressError = null, canContinue = false, + selectedAsset = null, + availableAssets = emptyList(), + tokenAmountInput = "", + parsedTokenAmount = null, + tokenAmountError = null, eventSink = {}, ) } @@ -114,14 +120,25 @@ class PaymentEntryPresenter @AssistedInject constructor( // Track resolved address separately so we can use it for validation var resolvedCardanoAddress by remember { mutableStateOf(null) } + // Asset selection state + var selectedAsset by remember { mutableStateOf(null) } + var availableAssets by remember { mutableStateOf>(emptyList()) } + var tokenAmountInput by remember { mutableStateOf("") } + LaunchedEffect(walletInitialized) { if (walletInitialized) { val sessionId = matrixClient.sessionId senderAddress = walletManager.getAddress(sessionId).getOrNull() senderAddress?.let { address -> + // Get balance cardanoClient.getBalance(address).onSuccess { balance -> senderBalanceLovelace = balance } + // Get available assets + cardanoClient.getAddressAssets(address).onSuccess { assets -> + availableAssets = assets + Timber.tag(TAG).d("Loaded ${assets.size} native assets") + } } } } @@ -229,8 +246,18 @@ class PaymentEntryPresenter @AssistedInject constructor( else -> resolvedCardanoAddress } + // Amount validation depends on whether we're sending a token val parsedAmountLovelace = parseAmountInput(amountInput) - val amountError = validateAmount(parsedAmountLovelace, amountInput) + val amountError = if (selectedAsset != null) { + // For token sends, ADA amount field is hidden but we still validate min UTXO + null + } else { + validateAmount(parsedAmountLovelace, amountInput) + } + + // Token amount validation + val parsedTokenAmount = parseTokenAmount(tokenAmountInput, selectedAsset) + val tokenAmountError = validateTokenAmount(parsedTokenAmount, tokenAmountInput, selectedAsset) val isCardanoAddress = CARDANO_ADDRESS_REGEX.matches(recipientInput) val isMatrixUser = MATRIX_USER_REGEX.matches(recipientInput) @@ -239,11 +266,23 @@ class PaymentEntryPresenter @AssistedInject constructor( // Recipient is valid if we have a final resolved address val isValidRecipient = finalResolvedAddress != null - val canContinue = parsedAmountLovelace != null && - parsedAmountLovelace >= MIN_AMOUNT_LOVELACE && - amountError == null && - isValidRecipient && - (recipientError == null || needsManualEntry) // Allow continue in manual entry mode if address is valid + + // Can continue logic + val canContinue = if (selectedAsset != null) { + // Token send: need valid token amount and recipient + parsedTokenAmount != null && + parsedTokenAmount > 0 && + tokenAmountError == null && + isValidRecipient && + (recipientError == null || needsManualEntry) + } else { + // ADA send: need valid ADA amount and recipient + parsedAmountLovelace != null && + parsedAmountLovelace >= MIN_AMOUNT_LOVELACE && + amountError == null && + isValidRecipient && + (recipientError == null || needsManualEntry) + } fun handleEvent(event: PaymentFlowEvents) { when (event) { @@ -257,6 +296,14 @@ class PaymentEntryPresenter @AssistedInject constructor( is PaymentFlowEvents.ManualAddressChanged -> { manualAddressInput = event.address } + is PaymentFlowEvents.AssetSelected -> { + selectedAsset = event.asset + // Clear token amount when switching assets + tokenAmountInput = "" + } + is PaymentFlowEvents.TokenAmountChanged -> { + tokenAmountInput = event.amount + } else -> Unit } } @@ -284,6 +331,11 @@ class PaymentEntryPresenter @AssistedInject constructor( recipientError = if (needsManualEntry) null else recipientError, // Hide error in manual entry mode manualAddressError = manualAddressError, canContinue = canContinue, + selectedAsset = selectedAsset, + availableAssets = availableAssets, + tokenAmountInput = tokenAmountInput, + parsedTokenAmount = parsedTokenAmount, + tokenAmountError = tokenAmountError, eventSink = ::handleEvent, ) } @@ -314,6 +366,25 @@ class PaymentEntryPresenter @AssistedInject constructor( } } + private fun parseTokenAmount(input: String, asset: NativeAsset?): Long? { + if (asset == null || input.isBlank()) return null + return try { + val decimal = BigDecimal(input.trim()) + if (decimal <= BigDecimal.ZERO) return null + + // Apply decimals if the token has them + val decimals = asset.decimals ?: 0 + if (decimals > 0) { + val multiplier = BigDecimal.TEN.pow(decimals) + decimal.multiply(multiplier).toLong() + } else { + decimal.toLong() + } + } catch (e: Exception) { + null + } + } + private fun validateAmount(lovelace: Lovelace?, input: String): String? { if (input.isBlank()) return null if (lovelace == null) return "Invalid amount" @@ -322,6 +393,15 @@ class PaymentEntryPresenter @AssistedInject constructor( return null } + private fun validateTokenAmount(amount: Long?, input: String, asset: NativeAsset?): String? { + if (asset == null) return null // Not sending a token + if (input.isBlank()) return null + if (amount == null) return "Invalid amount" + if (amount <= 0) return "Amount must be positive" + if (amount > asset.quantity) return "Insufficient balance (have ${asset.formatQuantity()})" + return null + } + private fun validateRecipient( input: String, isCardanoAddress: Boolean, diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryState.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryState.kt index a5c8aa00d1..ff5b81eaa5 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryState.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryState.kt @@ -6,6 +6,7 @@ package io.element.android.features.wallet.impl.payment +import io.element.android.features.wallet.api.NativeAsset import io.element.android.features.wallet.impl.slash.Lovelace /** @@ -35,6 +36,16 @@ data class PaymentEntryState( /** Validation error for manual address entry field. */ val manualAddressError: String?, val canContinue: Boolean, + /** Currently selected asset (null = ADA). */ + val selectedAsset: NativeAsset?, + /** Available native assets in the wallet. */ + val availableAssets: List, + /** Token amount input (when sending a native asset). */ + val tokenAmountInput: String, + /** Parsed token amount. */ + val parsedTokenAmount: Long?, + /** Token amount validation error. */ + val tokenAmountError: String?, val eventSink: (PaymentFlowEvents) -> Unit, ) { val parsedAmountAda: String? @@ -47,6 +58,14 @@ data class PaymentEntryState( val needsManualAddressEntry: Boolean get() = recipientResolutionState is RecipientResolutionState.NeedsManualEntry + /** True if sending a native asset (token) instead of ADA. */ + val isSendingToken: Boolean + get() = selectedAsset != null + + /** Display name for the selected asset (or "ADA"). */ + val selectedAssetName: String + get() = selectedAsset?.name ?: "ADA" + companion object { /** Initial loading state while checking wallet. */ val Loading = PaymentEntryState( @@ -68,6 +87,11 @@ data class PaymentEntryState( recipientError = null, manualAddressError = null, canContinue = false, + selectedAsset = null, + availableAssets = emptyList(), + tokenAmountInput = "", + parsedTokenAmount = null, + tokenAmountError = null, eventSink = {}, ) } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryView.kt index a7c07dc045..ed2f39a940 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryView.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryView.kt @@ -6,6 +6,7 @@ package io.element.android.features.wallet.impl.payment +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,9 +24,12 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -35,6 +39,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType @@ -43,6 +51,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.wallet.api.NativeAsset import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button @@ -183,17 +192,54 @@ private fun PaymentFormContent( Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = state.amountInput, - onValueChange = { state.eventSink(PaymentFlowEvents.AmountChanged(it)) }, - label = { Text("Amount (ADA)") }, - placeholder = { Text("0.00") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - isError = state.amountError != null, - supportingText = state.amountError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) + // Asset selector (if there are tokens available) + if (state.availableAssets.isNotEmpty()) { + AssetSelector( + selectedAsset = state.selectedAsset, + availableAssets = state.availableAssets, + onAssetSelected = { state.eventSink(PaymentFlowEvents.AssetSelected(it)) }, + ) + } + + // Amount input — different based on selected asset + if (state.selectedAsset != null) { + // Token amount input + OutlinedTextField( + value = state.tokenAmountInput, + onValueChange = { state.eventSink(PaymentFlowEvents.TokenAmountChanged(it)) }, + label = { Text("Amount (${state.selectedAsset.name})") }, + placeholder = { Text("0") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + isError = state.tokenAmountError != null, + supportingText = if (state.tokenAmountError != null) { + { Text(state.tokenAmountError, color = MaterialTheme.colorScheme.error) } + } else { + { Text("Available: ${state.selectedAsset.formatQuantity()}") } + }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // Note about min ADA + Text( + text = "Note: Token sends include ~1.5 ADA for Cardano protocol requirements", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + // ADA amount input + OutlinedTextField( + value = state.amountInput, + onValueChange = { state.eventSink(PaymentFlowEvents.AmountChanged(it)) }, + label = { Text("Amount (ADA)") }, + placeholder = { Text("0.00") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + isError = state.amountError != null, + supportingText = state.amountError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } OutlinedTextField( value = state.recipientInput, @@ -256,6 +302,88 @@ private fun PaymentFormContent( } } +/** + * Asset selector dropdown. + */ +@Composable +private fun AssetSelector( + selectedAsset: NativeAsset?, + availableAssets: List, + onAssetSelected: (NativeAsset?) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Box { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = true } + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + "Asset", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + selectedAsset?.name ?: "ADA", + style = MaterialTheme.typography.bodyLarge, + ) + } + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = "Select asset", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + // ADA option + DropdownMenuItem( + text = { + Text("ADA") + }, + onClick = { + onAssetSelected(null) + expanded = false + } + ) + + // Available tokens + availableAssets.forEach { asset -> + DropdownMenuItem( + text = { + Column { + Text(asset.name) + Text( + "Balance: ${asset.formatQuantity()}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = { + onAssetSelected(asset) + expanded = false + } + ) + } + } + } + } +} + @Composable private fun TestnetWarningCard(modifier: Modifier = Modifier) { Card( @@ -499,74 +627,27 @@ internal class PaymentEntryStateProvider : PreviewParameterProvider Timber.tag(TAG).d("Transaction built successfully, hash: ${signedTx.txHash}") @@ -179,12 +201,23 @@ class PaymentProgressPresenter @AssistedInject constructor( "${CardanoNetworkConfig.EXPLORER_BASE_URL}/transaction/$it" } + // Build display amount + val displayAmount = if (isSendingToken && assetDisplayName != null) { + "$assetQuantity $assetDisplayName" + } else { + "${PaymentConfirmationState.formatAda(amountLovelace)} ADA" + } + return PaymentProgressState( txHash = txHash, txHashDisplay = txHash?.let { PaymentProgressState.truncateTxHash(it) }, explorerUrl = explorerUrl, amountLovelace = amountLovelace, amountAda = PaymentConfirmationState.formatAda(amountLovelace), + displayAmount = displayAmount, + isSendingToken = isSendingToken, + assetDisplayName = assetDisplayName, + assetQuantity = assetQuantity, recipientAddress = recipientAddress, txStatus = txStatus, submissionState = submissionState, diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressState.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressState.kt index 1b4d041b97..588422bd8c 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressState.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressState.kt @@ -18,6 +18,14 @@ data class PaymentProgressState( val explorerUrl: String?, val amountLovelace: Lovelace, val amountAda: String, + /** Formatted display amount (e.g., "10 ADA" or "1000 HOSKY"). */ + val displayAmount: String, + /** True if sending a native asset (token). */ + val isSendingToken: Boolean, + /** Display name of the token being sent. */ + val assetDisplayName: String?, + /** Quantity of the token being sent. */ + val assetQuantity: Long?, val recipientAddress: String, val txStatus: TxStatus, val submissionState: SubmissionState, diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressView.kt index bdb6f33bc7..477dd0d52c 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressView.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressView.kt @@ -145,7 +145,7 @@ fun PaymentProgressView( text = when (state.submissionState) { SubmissionState.Submitting -> "Please wait..." SubmissionState.Pending -> "Waiting for confirmation..." - SubmissionState.Confirmed -> "${state.amountAda} ADA sent" + SubmissionState.Confirmed -> "${state.displayAmount} sent" is SubmissionState.Failed -> state.errorMessage ?: "Transaction failed" SubmissionState.TakingTooLong -> "The network is busy. Your transaction may still confirm." }, @@ -348,6 +348,10 @@ internal class PaymentProgressStateProvider : PreviewParameterProvider { + fun createDefaultUtxos( + address: String, + totalLovelace: Long, + assets: List = emptyList(), + ): List { if (totalLovelace <= 0) return emptyList() // Create 2-3 UTxOs that sum to the total @@ -319,12 +324,14 @@ class FakeCardanoClient : CardanoClient { outputIndex = 0, amount = utxo1Amount, address = address, + assets = assets, // First UTXO holds the assets ), Utxo( txHash = "11223344556677889900aabbccdd11223344556677889900aabbccdd11223344", outputIndex = 1, amount = utxo2Amount, address = address, + assets = emptyList(), ), ) }