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:
parent
af05e51916
commit
a57fd79098
20 changed files with 648 additions and 100 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue