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
This commit is contained in:
Kayos 2026-03-29 10:58:17 -07:00
parent af05e51916
commit a57fd79098
20 changed files with 648 additions and 100 deletions

View file

@ -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()
}
}
}

View file

@ -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
}

View file

@ -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<UtxoAsset> = 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,
)

View file

@ -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,
)
}

View file

@ -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<SignedTransaction> = 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<Amount>()
// 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)

View file

@ -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)
}
}

View file

@ -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) {

View file

@ -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<Lovelace?>(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 = {},
)
}

View file

@ -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

View file

@ -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<PaymentConfirmationState> {
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 = {},
),
)
}

View file

@ -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()

View file

@ -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<String?>(null) }
// Asset selection state
var selectedAsset by remember { mutableStateOf<NativeAsset?>(null) }
var availableAssets by remember { mutableStateOf<List<NativeAsset>>(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,

View file

@ -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<NativeAsset>,
/** 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 = {},
)
}

View file

@ -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<NativeAsset>,
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<PaymentEntry
resolvedAddress = null,
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = true,
amountError = null, recipientError = null, manualAddressError = null,
canContinue = false, eventSink = {},
canContinue = false, selectedAsset = null, availableAssets = emptyList(),
tokenAmountInput = "", parsedTokenAmount = null, tokenAmountError = null,
eventSink = {},
),
// Address found from Matrix
// With available tokens
PaymentEntryState(
noWalletSetup = false, isCheckingWallet = false,
amountInput = "10", recipientInput = "@alice:matrix.org", manualAddressInput = "",
prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = 10_000_000L, isValidRecipient = true,
recipientResolutionState = RecipientResolutionState.Found(
"@alice:matrix.org",
"addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer"
),
resolvedAddress = "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer",
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = true,
amountError = null, recipientError = null, manualAddressError = null,
canContinue = true, eventSink = {},
),
// Handle resolved
PaymentEntryState(
noWalletSetup = false, isCheckingWallet = false,
amountInput = "10", recipientInput = "\$cobb", manualAddressInput = "",
prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = 10_000_000L, isValidRecipient = true,
recipientResolutionState = RecipientResolutionState.HandleResolved(
"\$cobb",
"addr1q85l4twecj9erkh49uv73w0p5ywsu7q7su7hpk3pangzd4x4n5czgvze3u5zflj9v2a4ttmdhtfr2rfdx0g4pp6p0tzs0h79mz"
),
resolvedAddress = "addr1q85l4twecj9erkh49uv73w0p5ywsu7q7su7hpk3pangzd4x4n5czgvze3u5zflj9v2a4ttmdhtfr2rfdx0g4pp6p0tzs0h79mz",
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = false,
amountError = null, recipientError = null, manualAddressError = null,
canContinue = true, eventSink = {},
),
// Manual entry needed - empty
PaymentEntryState(
noWalletSetup = false, isCheckingWallet = false,
amountInput = "10", recipientInput = "@bob:matrix.org", manualAddressInput = "",
prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = 10_000_000L, isValidRecipient = false,
recipientResolutionState = RecipientResolutionState.NeedsManualEntry("@bob:matrix.org", "Bob"),
resolvedAddress = null,
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = true,
amountError = null, recipientError = null, manualAddressError = null,
canContinue = false, eventSink = {},
),
// Manual entry with valid address
PaymentEntryState(
noWalletSetup = false, isCheckingWallet = false,
amountInput = "10", recipientInput = "@bob:matrix.org",
manualAddressInput = "addr_test1qp2fg770ddmqxxduasjsas8rgimrhknmqjn43mj74g7ta2",
prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = 10_000_000L, isValidRecipient = true,
recipientResolutionState = RecipientResolutionState.NeedsManualEntry("@bob:matrix.org", "Bob"),
resolvedAddress = "addr_test1qp2fg770ddmqxxduasjsas8rgimrhknmqjn43mj74g7ta2",
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = true,
amountError = null, recipientError = null, manualAddressError = null,
canContinue = true, eventSink = {},
),
// No wallet state
PaymentEntryState(
noWalletSetup = true, isCheckingWallet = false,
amountInput = "", recipientInput = "", manualAddressInput = "",
prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = null, isValidRecipient = false,
recipientResolutionState = RecipientResolutionState.NotNeeded,
resolvedAddress = null,
senderAddress = null, senderBalanceAda = null, isTestnet = false,
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = true,
amountError = null, recipientError = null, manualAddressError = null,
canContinue = false, eventSink = {},
canContinue = false, selectedAsset = null,
availableAssets = listOf(
NativeAsset("abc123", "484f534b59", 1000000L, "HOSKY", null),
NativeAsset("def456", "4d494e", 500L, "MIN", null),
),
tokenAmountInput = "", parsedTokenAmount = null, tokenAmountError = null,
eventSink = {},
),
// Loading state
PaymentEntryState.Loading,

View file

@ -6,6 +6,8 @@
package io.element.android.features.wallet.impl.payment
import io.element.android.features.wallet.api.NativeAsset
/**
* Events for the payment flow state machine.
*/
@ -15,6 +17,10 @@ sealed interface PaymentFlowEvents {
data class RecipientChanged(val recipient: String) : PaymentFlowEvents
/** Manual address entry when recipient has no linked wallet. */
data class ManualAddressChanged(val address: String) : PaymentFlowEvents
/** Select an asset to send (null = ADA). */
data class AssetSelected(val asset: NativeAsset?) : PaymentFlowEvents
/** Token amount changed (when sending a native asset). */
data class TokenAmountChanged(val amount: String) : PaymentFlowEvents
data object Continue : PaymentFlowEvents
data object Cancel : PaymentFlowEvents

View file

@ -38,6 +38,10 @@ class PaymentProgressNode @AssistedInject constructor(
val recipientAddress: String,
val amountLovelace: Lovelace,
val roomId: RoomId,
val assetPolicyId: String?,
val assetName: String?,
val assetQuantity: Long?,
val assetDisplayName: String?,
) : NodeInputs, Parcelable
interface Callback : Plugin {
@ -53,6 +57,10 @@ class PaymentProgressNode @AssistedInject constructor(
recipientAddress = inputs.recipientAddress,
amountLovelace = inputs.amountLovelace,
roomId = inputs.roomId,
assetPolicyId = inputs.assetPolicyId,
assetName = inputs.assetName,
assetQuantity = inputs.assetQuantity,
assetDisplayName = inputs.assetDisplayName,
)
}

View file

@ -39,6 +39,10 @@ class PaymentProgressPresenter @AssistedInject constructor(
@Assisted private val recipientAddress: String,
@Assisted private val amountLovelace: Lovelace,
@Assisted private val roomId: RoomId,
@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 transactionBuilder: TransactionBuilder,
@ -49,7 +53,15 @@ class PaymentProgressPresenter @AssistedInject constructor(
@AssistedFactory
interface Factory {
fun create(recipientAddress: String, amountLovelace: Lovelace, roomId: RoomId): PaymentProgressPresenter
fun create(
recipientAddress: String,
amountLovelace: Lovelace,
roomId: RoomId,
assetPolicyId: String?,
assetName: String?,
assetQuantity: Long?,
assetDisplayName: String?,
): PaymentProgressPresenter
}
companion object {
@ -57,6 +69,9 @@ class PaymentProgressPresenter @AssistedInject constructor(
private const val TIMEOUT_THRESHOLD_MS = 10 * 60 * 1000L
}
private val isSendingToken: Boolean
get() = assetPolicyId != null && assetQuantity != null
@Composable
override fun present(): PaymentProgressState {
val sessionId = matrixClient.sessionId
@ -89,9 +104,16 @@ class PaymentProgressPresenter @AssistedInject constructor(
toAddress = recipientAddress,
amountLovelace = amountLovelace,
sessionId = sessionId,
assetPolicyId = assetPolicyId,
assetName = assetName,
assetQuantity = assetQuantity,
)
Timber.tag(TAG).d("Building and signing transaction...")
if (isSendingToken) {
Timber.tag(TAG).d("Building token tx: $assetQuantity $assetDisplayName to ${recipientAddress.take(20)}...")
} else {
Timber.tag(TAG).d("Building ADA tx: $amountLovelace lovelace to ${recipientAddress.take(20)}...")
}
transactionBuilder.buildAndSign(request).onSuccess { signedTx ->
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,

View file

@ -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,

View file

@ -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<PaymentPr
explorerUrl = null,
amountLovelace = 10_000_000L,
amountAda = "10",
displayAmount = "10 ADA",
isSendingToken = false,
assetDisplayName = null,
assetQuantity = null,
recipientAddress = "addr_test1...",
txStatus = TxStatus.PENDING,
submissionState = SubmissionState.Submitting,
@ -362,6 +366,10 @@ internal class PaymentProgressStateProvider : PreviewParameterProvider<PaymentPr
explorerUrl = "https://preprod.cardanoscan.io/transaction/abc123...",
amountLovelace = 10_000_000L,
amountAda = "10",
displayAmount = "10 ADA",
isSendingToken = false,
assetDisplayName = null,
assetQuantity = null,
recipientAddress = "addr_test1...",
txStatus = TxStatus.PENDING,
submissionState = SubmissionState.Pending,
@ -376,6 +384,10 @@ internal class PaymentProgressStateProvider : PreviewParameterProvider<PaymentPr
explorerUrl = "https://preprod.cardanoscan.io/transaction/abc123...",
amountLovelace = 10_000_000L,
amountAda = "10",
displayAmount = "10 ADA",
isSendingToken = false,
assetDisplayName = null,
assetQuantity = null,
recipientAddress = "addr_test1...",
txStatus = TxStatus.CONFIRMED,
submissionState = SubmissionState.Confirmed,
@ -390,6 +402,10 @@ internal class PaymentProgressStateProvider : PreviewParameterProvider<PaymentPr
explorerUrl = null,
amountLovelace = 10_000_000L,
amountAda = "10",
displayAmount = "10 ADA",
isSendingToken = false,
assetDisplayName = null,
assetQuantity = null,
recipientAddress = "addr_test1...",
txStatus = TxStatus.FAILED,
submissionState = SubmissionState.Failed("Transaction rejected: insufficient funds"),

View file

@ -13,6 +13,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
/**
* Fake implementation of [CardanoClient] for testing.
@ -306,7 +307,11 @@ class FakeCardanoClient : CardanoClient {
* Creates a default set of UTxOs for testing.
* Splits the balance into multiple UTxOs for realistic scenarios.
*/
fun createDefaultUtxos(address: String, totalLovelace: Long): List<Utxo> {
fun createDefaultUtxos(
address: String,
totalLovelace: Long,
assets: List<UtxoAsset> = emptyList(),
): List<Utxo> {
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(),
),
)
}