diff --git a/app/src/main/java/one/mixin/android/api/response/web3/ParsedTx.kt b/app/src/main/java/one/mixin/android/api/response/web3/ParsedTx.kt index eaf8908059..f9e15ec127 100644 --- a/app/src/main/java/one/mixin/android/api/response/web3/ParsedTx.kt +++ b/app/src/main/java/one/mixin/android/api/response/web3/ParsedTx.kt @@ -15,7 +15,9 @@ data class ParsedTx( @SerializedName("code") val code: Int? = null ) { - fun noBalanceChange(): Boolean = instructions?.isNotEmpty() == true && balanceChanges.isNullOrEmpty() + fun hasChanges(): Boolean = !balanceChanges.isNullOrEmpty() || !approves.isNullOrEmpty() + + fun noBalanceChange(): Boolean = instructions?.isNotEmpty() == true && !hasChanges() } data class BalanceChange( @@ -77,8 +79,6 @@ data class ParsedInstruction( val instructionName: String, @SerializedName("items") val items: List? = null, - @SerializedName("token_changes") - val tokenChanges: List? = null, @SerializedName("info") val info: String? = null, ) @@ -89,12 +89,3 @@ data class Item( @SerializedName("value") val value: String ) - -data class TokenChange( - @SerializedName("address") - val address: String, - @SerializedName("amount") - val amount: Long, - @SerializedName("is_pay") - val isPay: Boolean -) diff --git a/app/src/main/java/one/mixin/android/repository/PendingTransactionAssetChanges.kt b/app/src/main/java/one/mixin/android/repository/PendingTransactionAssetChanges.kt new file mode 100644 index 0000000000..62fbcc635c --- /dev/null +++ b/app/src/main/java/one/mixin/android/repository/PendingTransactionAssetChanges.kt @@ -0,0 +1,28 @@ +package one.mixin.android.repository + +import one.mixin.android.api.response.web3.BalanceChange +import one.mixin.android.db.web3.vo.AssetChange + +internal data class PendingAssetChanges( + val senders: List, + val receivers: List, +) + +internal fun pendingAssetChanges(balanceChanges: List?): PendingAssetChanges { + val senders = mutableListOf() + val receivers = mutableListOf() + balanceChanges.orEmpty().forEach { change -> + val amount = change.amount.toBigDecimalOrNull() ?: return@forEach + val assetChange = AssetChange( + assetId = change.assetId, + amount = amount.abs().toPlainString(), + from = change.from, + to = change.to, + ) + when (amount.signum()) { + -1 -> senders.add(assetChange) + 1 -> receivers.add(assetChange) + } + } + return PendingAssetChanges(senders, receivers) +} diff --git a/app/src/main/java/one/mixin/android/repository/TokenRepository.kt b/app/src/main/java/one/mixin/android/repository/TokenRepository.kt index f4f1436090..6f0685eddf 100644 --- a/app/src/main/java/one/mixin/android/repository/TokenRepository.kt +++ b/app/src/main/java/one/mixin/android/repository/TokenRepository.kt @@ -1160,32 +1160,11 @@ class TokenRepository rate: BigDecimal?, ): Web3Transaction { val resolvedAddress = gaslessPendingTransaction?.address ?: raw.account - val senders = mutableListOf() - val receivers = mutableListOf() + val pendingChanges = pendingAssetChanges(raw.simulateTx?.balanceChanges) + val senders = pendingChanges.senders + val receivers = pendingChanges.receivers val approvals = mutableListOf() - raw.simulateTx?.balanceChanges?.forEach { bc -> - val amt = bc.amount.toBigDecimalOrNull() - if (amt != null) { - receivers.add( - AssetChange( - assetId = bc.assetId, - amount = amt.abs().toPlainString(), - from = bc.from, - to = bc.to - ) - ) - senders.add( - AssetChange( - assetId = bc.assetId, - amount = amt.toPlainString(), - from = bc.from, - to = bc.to, - ) - ) - } - } - raw.simulateTx?.approves?.forEach { approve -> approvals.add( AssetChange( @@ -1203,32 +1182,26 @@ class TokenRepository val txType = when { assetId in Constants.Web3UtxoChainIds -> TransactionType.TRANSFER_OUT.value raw.simulateTx?.approves?.isNotEmpty() == true -> TransactionType.APPROVAL.value - (raw.simulateTx?.balanceChanges?.size ?: 0) > 1 -> TransactionType.SWAP.value - raw.simulateTx?.balanceChanges?.size == 1 -> TransactionType.TRANSFER_OUT.value + senders.isNotEmpty() && receivers.isNotEmpty() -> TransactionType.SWAP.value + senders.isNotEmpty() -> TransactionType.TRANSFER_OUT.value + receivers.isNotEmpty() -> TransactionType.TRANSFER_IN.value else -> TransactionType.UNKNOWN.value } when (txType) { TransactionType.SWAP.value -> { - raw.simulateTx?.balanceChanges?.forEach { bc -> - val amt = bc.amount.toBigDecimalOrNull() - if (amt != null) { - if (amt < BigDecimal.ZERO) { - sendAssetId = bc.assetId - } else if (amt > BigDecimal.ZERO) { - receiveAssetId = bc.assetId - } - } - } + sendAssetId = senders.firstOrNull()?.assetId + receiveAssetId = receivers.firstOrNull()?.assetId } TransactionType.TRANSFER_OUT.value -> { - raw.simulateTx?.balanceChanges?.firstOrNull { - it.amount.toBigDecimalOrNull()?.let { amt -> amt < BigDecimal.ZERO } == true - }?.let { + senders.firstOrNull()?.let { sendAssetId = it.assetId receiveAssetId = it.assetId } } + TransactionType.TRANSFER_IN.value -> { + receiveAssetId = receivers.firstOrNull()?.assetId + } else -> { sendAssetId = assetId receiveAssetId = assetId diff --git a/app/src/main/java/one/mixin/android/ui/common/BalanceChangePresentation.kt b/app/src/main/java/one/mixin/android/ui/common/BalanceChangePresentation.kt index 7ec9657901..0b0e48aa61 100644 --- a/app/src/main/java/one/mixin/android/ui/common/BalanceChangePresentation.kt +++ b/app/src/main/java/one/mixin/android/ui/common/BalanceChangePresentation.kt @@ -2,10 +2,7 @@ package one.mixin.android.ui.common import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import one.mixin.android.Constants import one.mixin.android.compose.theme.MixinAppTheme -import one.mixin.android.extension.defaultSharedPreferences internal enum class BalanceChangeTone { POSITIVE, @@ -26,7 +23,7 @@ internal fun balanceChangePresentation( amount.withBalanceChangeSign() } else { val magnitude = amount.toBigDecimalOrNull()?.let { it.abs().toPlainString() } - ?: amount.removePrefix("+").removePrefix("-") + ?: amount.trimStart('+', '-') if (isReceive) "+$magnitude" else "-$magnitude" } val tone = when (displayAmount.toBigDecimalOrNull()?.signum()) { @@ -39,19 +36,9 @@ internal fun balanceChangePresentation( @Composable internal fun BalanceChangeTone.toColor(): Color { - val quoteColorReversed = LocalContext.current.defaultSharedPreferences - .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) return when (this) { - BalanceChangeTone.POSITIVE -> if (quoteColorReversed) { - MixinAppTheme.colors.walletRed - } else { - MixinAppTheme.colors.walletGreen - } - BalanceChangeTone.NEGATIVE -> if (quoteColorReversed) { - MixinAppTheme.colors.walletGreen - } else { - MixinAppTheme.colors.walletRed - } + BalanceChangeTone.POSITIVE -> MixinAppTheme.colors.walletGreen + BalanceChangeTone.NEGATIVE -> MixinAppTheme.colors.walletRed BalanceChangeTone.PLAIN -> MixinAppTheme.colors.textPrimary } } diff --git a/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt b/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt index 46ef0da34f..3b0ab2dc60 100644 --- a/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt @@ -18,7 +18,6 @@ import one.mixin.android.Constants import one.mixin.android.Constants.ChainId.ETHEREUM_CHAIN_ID import one.mixin.android.Constants.ChainId.SOLANA_CHAIN_ID import one.mixin.android.Constants.MIXIN_FREE_FEE -import one.mixin.android.Constants.RouteConfig.ROUTE_BOT_USER_ID import one.mixin.android.MixinApplication import one.mixin.android.R import one.mixin.android.api.MixinResponse @@ -50,7 +49,6 @@ import one.mixin.android.api.response.TransactionResponse import one.mixin.android.api.response.getTransactionResult import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.api.response.signature.SignatureAction -import one.mixin.android.api.response.web3.ParsedTx import one.mixin.android.api.service.UtxoService import one.mixin.android.crypto.CryptoWalletHelper import one.mixin.android.crypto.PinCipher @@ -1862,31 +1860,6 @@ class BottomSheetViewModel } } - suspend fun simulateWeb3Tx(tx: String, chainId: String, from: String?, to: String?): ParsedTx? { - var meet401 = false - var parsedTx: ParsedTx? = null - handleMixinResponse( - invokeNetwork = { tokenRepository.simulateWeb3Tx(Web3RawTransactionRequest(chainId, tx, from, to)) }, - successBlock = { parsedTx = it.data }, - failureBlock = { - if (it.errorCode == ErrorHandler.SIMULATE_TRANSACTION_FAILED) { - parsedTx = ParsedTx(code = ErrorHandler.SIMULATE_TRANSACTION_FAILED) - return@handleMixinResponse true - } else if (it.errorCode == 401) { - meet401 = true - return@handleMixinResponse true - } - return@handleMixinResponse false - } - ) - if (parsedTx == null && meet401) { - userRepository.getBotPublicKey(ROUTE_BOT_USER_ID, true) - return simulateWeb3Tx(tx, chainId, from, to) - } else { - return parsedTx - } - } - suspend fun estimateFee(request: EstimateFeeRequest) = web3Repository.estimateFee(request) suspend fun bindReferral(code: String) = userRepository.bindReferral(code) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/BrowserPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/BrowserPage.kt index c4c936e703..73da11fa11 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/BrowserPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/BrowserPage.kt @@ -336,11 +336,18 @@ fun BrowserPage( val customFeeValue = feeAmount?.toBigDecimalOrNull() val feePrice = feeToken?.priceUsd?.toBigDecimalOrNull() ?: asset.priceUSD() val fee = customFeeValue ?: tipGas?.displayValue(transaction?.maxFeePerGas) ?: solanaFee?.stripTrailingZeros()?: utxoFee?.stripTrailingZeros() ?: BigDecimal.ZERO + val isFeeLoading = step != WalletConnectBottomSheetDialogFragment.Step.Error && when { + transaction != null -> tipGas == null + chain == Chain.Solana && type == JsSignMessage.TYPE_RAW_TRANSACTION -> solanaFee == null + else -> false + } + val isFeeReady = step != WalletConnectBottomSheetDialogFragment.Step.Error && !isFeeLoading if (fee == BigDecimal.ZERO) { FeeInfo( amount = "$fee", fee = fee.multiply(feePrice), - isFree = isFeeWaived, + isFree = isFeeWaived && isFeeReady, + isLoading = isFeeLoading, onFreeClick = onFreeClick, ) } else { @@ -348,7 +355,8 @@ fun BrowserPage( amount = "$fee ${feeToken?.symbol ?: asset?.symbol ?: ""}", fee = fee.multiply(feePrice), gasPrice = tipGas?.displayGas(transaction?.maxFeePerGas)?.toPlainString(), - isFree = isFeeWaived, + isFree = isFeeWaived && isFeeReady, + isLoading = isFeeLoading, onFreeClick = onFreeClick, ) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetDialogFragment.kt index 3ac176999d..73a190b7b6 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetDialogFragment.kt @@ -98,6 +98,9 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag const val ARGS_IS_FEE_FREE = "args_is_fee_free" const val ARGS_FEE_AMOUNT = "args_fee_amount" const val ARGS_FEE_TOKEN = "args_fee_token" + const val ARGS_TIP_GAS_LIMIT = "args_tip_gas_limit" + const val ARGS_TIP_GAS_MAX_FEE_PER_GAS = "args_tip_gas_max_fee_per_gas" + const val ARGS_TIP_GAS_MAX_PRIORITY_FEE_PER_GAS = "args_tip_gas_max_priority_fee_per_gas" fun newInstance( jsSignMessage: JsSignMessage, @@ -111,6 +114,7 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag toAddress: String? = null, toUser: User? = null, isFeeWaived: Boolean = false, + tipGas: TipGas? = null, ) = BrowserWalletBottomSheetDialogFragment().withArgs { putParcelable(ARGS_MESSAGE, jsSignMessage) putString( @@ -128,6 +132,11 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag toAddress?.let { putString(ARGS_TO_ADDRESS, it) } toUser?.let { putParcelable(ARGS_TO_USER, it) } putBoolean(ARGS_IS_FEE_FREE, isFeeWaived) + tipGas?.let { + putString(ARGS_TIP_GAS_LIMIT, it.gasLimit.toString()) + putString(ARGS_TIP_GAS_MAX_FEE_PER_GAS, it.maxFeePerGas.toString()) + putString(ARGS_TIP_GAS_MAX_PRIORITY_FEE_PER_GAS, it.maxPriorityFeePerGas.toString()) + } } } @@ -153,6 +162,13 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag requireArguments().getParcelableCompat(ARGS_FEE_TOKEN, Web3TokenItem::class.java) } private val isFeeWaived by lazy { requireArguments().getBoolean(ARGS_IS_FEE_FREE, false) } + private val initialTipGas by lazy { + val args = requireArguments() + val gasLimit = args.getString(ARGS_TIP_GAS_LIMIT)?.toBigIntegerOrNull() ?: return@lazy null + val maxFeePerGas = args.getString(ARGS_TIP_GAS_MAX_FEE_PER_GAS)?.toBigIntegerOrNull() ?: return@lazy null + val maxPriorityFeePerGas = args.getString(ARGS_TIP_GAS_MAX_PRIORITY_FEE_PER_GAS)?.toBigIntegerOrNull() ?: return@lazy null + TipGas(currentChain.chainId, gasLimit, maxFeePerGas, maxPriorityFeePerGas) + } private val currentChain by lazy { token?.getChainFromName() ?: Web3Signer.currentChain } @@ -182,6 +198,7 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag super.onViewCreated(view, savedInstanceState) token = requireArguments().getParcelableCompat(ARGS_TOKEN, Web3TokenItem::class.java) amount = requireArguments().getString(ARGS_AMOUNT) + tipGas = initialTipGas if (isAccountUnavailable()) { val message = getString(R.string.not_support_network, currentChain.symbol) settleError(WalletErrorCode.UNSUPPORTED_METHOD, message) @@ -315,38 +332,46 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag } val assetId = chain.getWeb3ChainId() val transaction = signMessage.wcEthereumTransaction ?: return + val cachedTipGas = tipGas + var useCachedTipGas = cachedTipGas != null tickerFlow(15.seconds) .onEach { asset = viewModel.refreshAsset(assetId) try { - tipGas = withContext(Dispatchers.IO) { - val r = runCatching { - viewModel.estimateFee( - EstimateFeeRequest( - assetId, - null, - transaction.data, - transaction.from, - transaction.to, - transaction.value, + val currentTipGas = if (useCachedTipGas) { + useCachedTipGas = false + cachedTipGas + } else { + withContext(Dispatchers.IO) { + val r = runCatching { + viewModel.estimateFee( + EstimateFeeRequest( + assetId, + null, + transaction.data, + transaction.from, + transaction.to, + transaction.value, + ) ) - ) - }.getOrNull() - if (r?.isSuccess != true) { - step = Step.Error - ErrorHandler.handleMixinError(r?.errorCode ?: 0, r?.errorDescription ?: "") - return@withContext null + }.getOrNull() + if (r?.isSuccess != true) { + step = Step.Error + ErrorHandler.handleMixinError(r?.errorCode ?: 0, r?.errorDescription ?: "") + return@withContext null + } + buildTipGas(chain.chainId, r.data!!) } - buildTipGas(chain.chainId, r.data!!) } ?: return@onEach - insufficientGas = checkGas(token, chainToken, tipGas, transaction.value, transaction.maxFeePerGas) + tipGas = currentTipGas + insufficientGas = checkGas(token, chainToken, currentTipGas, transaction.value, transaction.maxFeePerGas) if (insufficientGas) { handleException(IllegalArgumentException(requireContext().getString(R.string.insufficient_gas, chainToken?.symbol ?: currentChain.symbol))) } val hex = Web3Signer.ethPreviewTransaction( Web3Signer.evmAddress, transaction, - tipGas!!, + currentTipGas, chain = token?.getChainFromName() ) { _ -> val nonce = rpc.nonceAt(currentChain.assetId, Web3Signer.evmAddress) ?: throw IllegalArgumentException("failed to get nonce") diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/GasCheckBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/GasCheckBottomSheetDialogFragment.kt index b13e8ab90c..f0b6aafeef 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/GasCheckBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/GasCheckBottomSheetDialogFragment.kt @@ -257,7 +257,7 @@ class GasCheckBottomSheetDialogFragment : BottomSheetDialogFragment() { private val viewModel by viewModels() - private fun showBrowserWalletBottomSheet() { + private fun showBrowserWalletBottomSheet(tipGas: TipGas? = null) { if (!isAdded) return if (swapResult != null) { SwapTransferBottomSheetDialogFragment.newInstance(swapResult!!, fromToken, toToken) @@ -272,13 +272,14 @@ class GasCheckBottomSheetDialogFragment : BottomSheetDialogFragment() { .show(requireActivity().supportFragmentManager, SwapTransferBottomSheetDialogFragment.TAG) } else { val fragment = BrowserWalletBottomSheetDialogFragment.newInstance( - signMessage, - url, - title, - amount, - token, - chainToken, - toAddress + jsSignMessage = signMessage, + url = url, + title = title, + amount = amount, + token = token, + chainToken = chainToken, + toAddress = toAddress, + tipGas = tipGas, ) onDismiss?.let { it -> fragment.setOnDismiss(it) @@ -381,7 +382,7 @@ class GasCheckBottomSheetDialogFragment : BottomSheetDialogFragment() { } } else { Timber.e("Sufficient gas for chain: ${chain.chainId}, gas: ${tipGas.maxFeePerGas} ${tipGas.gasLimit}") - showBrowserWalletBottomSheet() + showBrowserWalletBottomSheet(tipGas) } } catch (e: Exception) { showError(ErrorHandler.getErrorMessage(e)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt b/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt index c4346cb626..dc5880d879 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt @@ -69,7 +69,6 @@ import one.mixin.android.tip.wc.internal.Chain import one.mixin.android.ui.common.balanceChangePresentation import one.mixin.android.ui.common.toColor import one.mixin.android.ui.home.web3.Web3ViewModel -import one.mixin.android.util.ErrorHandler import one.mixin.android.util.GsonHelper import one.mixin.android.vo.priceUSD import one.mixin.android.vo.safe.Token @@ -224,128 +223,126 @@ fun ParsedTxPreview( .padding(horizontal = 20.dp), horizontalAlignment = Alignment.Start, ) { - if (parsedTx == null) { - BalanceChangeHead() - CircularProgressIndicator( - modifier = Modifier.size(32.dp), - color = MixinAppTheme.colors.accent, - ) - } else if (parsedTx.instructions?.isEmpty() == true) { - BalanceChangeHead() - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Bottom, - ) { - Text( - modifier = Modifier.alignByBaseline(), - text = stringResource(id = R.string.preview_unavailable), - color = MixinAppTheme.colors.textPrimary, - fontFamily = FontFamily(Font(R.font.mixin_font)), - fontSize = 18.sp, - fontWeight = FontWeight.W600 - ) - Box(modifier = Modifier.weight(1f)) - CoilImage( - model = asset?.iconUrl, - modifier = Modifier - .size(32.dp) - .clip(CircleShape), - placeholder = R.drawable.ic_avatar_place_holder, - ) - } - } else if (parsedTx.code == ErrorHandler.SIMULATE_TRANSACTION_FAILED) { - BalanceChangeHead() - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Bottom, - ) { - Text( - modifier = Modifier.alignByBaseline(), - text = stringResource(id = R.string.decode_transaction_failed_content), - color = MixinAppTheme.colors.red, - fontSize = 18.sp, - fontWeight = FontWeight.W600 + when { + parsedTx == null -> { + BalanceChangeHead() + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = MixinAppTheme.colors.accent, ) - Box(modifier = Modifier.weight(1f)) } - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(id = R.string.Unable_to_estimate_balance_changes), - color = MixinAppTheme.colors.red, - fontSize = 14.sp, - ) - } else if (parsedTx.balanceChanges.isNullOrEmpty() && parsedTx.approves.isNullOrEmpty()) { - BalanceChangeHead() - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Bottom, - ) { + parsedTx.code != null -> { + BalanceChangeHead() + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, + ) { + Text( + modifier = Modifier.alignByBaseline(), + text = stringResource(id = R.string.decode_transaction_failed_content), + color = MixinAppTheme.colors.red, + fontSize = 18.sp, + fontWeight = FontWeight.W600 + ) + Box(modifier = Modifier.weight(1f)) + } + Spacer(modifier = Modifier.height(8.dp)) Text( - modifier = Modifier.alignByBaseline(), - text = stringResource(id = R.string.No_balance_change_detected), + text = stringResource(id = R.string.Unable_to_estimate_balance_changes), color = MixinAppTheme.colors.red, - fontFamily = FontFamily(Font(R.font.mixin_font)), - fontSize = 18.sp, - fontWeight = FontWeight.W600 + fontSize = 14.sp, ) - Box(modifier = Modifier.weight(1f)) - } - } else if (parsedTx.approves.isNullOrEmpty().not() && parsedTx.balanceChanges.isNullOrEmpty().not()){ - BalanceChangeHead(R.string.preauthorize_amount) - parsedTx.approves.forEach { approve -> - ApproveChangeItem(approve) - Box(modifier = Modifier.height(10.dp)) - } - BalanceChangeHead() - parsedTx.balanceChanges.forEach { bc -> - BalanceChangeItem(balanceChange = bc) - Box(modifier = Modifier.height(10.dp)) - } - } else if (parsedTx.approves.isNullOrEmpty().not() && parsedTx.balanceChanges.isNullOrEmpty()){ - BalanceChangeHead(R.string.preauthorize_amount) - parsedTx.approves.firstOrNull()?.let { approve -> - ApproveChangeItem(approve) - } - Box(modifier = Modifier.height(10.dp)) - } else { - BalanceChangeHead() - val viewDetails = remember { mutableStateOf(false) } - val rotation by animateFloatAsState(if (viewDetails.value) 90f else 0f, label = "rotation") - if (parsedTx.balanceChanges?.size == 1) { - SingleBalanceChangeItem(bc = parsedTx.balanceChanges.first()) - Box(modifier = Modifier.height(10.dp)) - } else { - parsedTx.balanceChanges?.forEach { bc -> - BalanceChangeItem(balanceChange = bc) - Spacer(modifier = Modifier.height(10.dp)) - } } - if (solanaTxSource != null && !solanaTxSource.isInnerTx()) { + parsedTx.instructions?.isEmpty() == true && !parsedTx.hasChanges() -> { + BalanceChangeHead() Row( - modifier = Modifier - .fillMaxWidth() - .clickable { viewDetails.value = !viewDetails.value }, - verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, ) { - Icon( - painter = painterResource(R.drawable.ic_play_arrow), + Text( + modifier = Modifier.alignByBaseline(), + text = stringResource(id = R.string.preview_unavailable), + color = MixinAppTheme.colors.textPrimary, + fontFamily = FontFamily(Font(R.font.mixin_font)), + fontSize = 18.sp, + fontWeight = FontWeight.W600 + ) + Box(modifier = Modifier.weight(1f)) + CoilImage( + model = asset?.iconUrl, modifier = Modifier - .size(24.dp, 24.dp) - .rotate(rotation), - contentDescription = null, - tint = MixinAppTheme.colors.accent, + .size(32.dp) + .clip(CircleShape), + placeholder = R.drawable.ic_avatar_place_holder, ) + } + } + !parsedTx.hasChanges() -> { + BalanceChangeHead() + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, + ) { Text( - modifier = Modifier.padding(start = 4.dp), - text = stringResource(id = R.string.View_details), - color = MixinAppTheme.colors.accent, + modifier = Modifier.alignByBaseline(), + text = stringResource(id = R.string.No_balance_change_detected), + color = MixinAppTheme.colors.red, fontFamily = FontFamily(Font(R.font.mixin_font)), - fontSize = 14.sp, + fontSize = 18.sp, + fontWeight = FontWeight.W600 ) + Box(modifier = Modifier.weight(1f)) + } + } + else -> { + if (!parsedTx.approves.isNullOrEmpty()) { + BalanceChangeHead(R.string.preauthorize_amount) + parsedTx.approves.forEach { approve -> + ApproveChangeItem(approve) + Box(modifier = Modifier.height(10.dp)) + } + } + if (!parsedTx.balanceChanges.isNullOrEmpty()) { + BalanceChangeHead() + if (parsedTx.balanceChanges.size == 1) { + SingleBalanceChangeItem(bc = parsedTx.balanceChanges.first()) + Box(modifier = Modifier.height(10.dp)) + } else { + parsedTx.balanceChanges.forEach { bc -> + BalanceChangeItem(balanceChange = bc) + Spacer(modifier = Modifier.height(10.dp)) + } + } } - if (viewDetails.value) { - Box(modifier = Modifier.height(10.dp)) - Instructions(parsedTx.instructions ?: emptyList()) + val viewDetails = remember { mutableStateOf(false) } + val rotation by animateFloatAsState(if (viewDetails.value) 90f else 0f, label = "rotation") + if (solanaTxSource != null && !solanaTxSource.isInnerTx()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { viewDetails.value = !viewDetails.value }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_play_arrow), + modifier = Modifier + .size(24.dp, 24.dp) + .rotate(rotation), + contentDescription = null, + tint = MixinAppTheme.colors.accent, + ) + Text( + modifier = Modifier.padding(start = 4.dp), + text = stringResource(id = R.string.View_details), + color = MixinAppTheme.colors.accent, + fontFamily = FontFamily(Font(R.font.mixin_font)), + fontSize = 14.sp, + ) + } + if (viewDetails.value) { + Box(modifier = Modifier.height(10.dp)) + Instructions(parsedTx.instructions ?: emptyList()) + } } } } diff --git a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt index c5a56b8af2..1897b2b75c 100644 --- a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt +++ b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt @@ -131,11 +131,8 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) lateinit var rpc: Rpc private fun formatAmountWithSign(amount: String, positive: Boolean): String { - return if (positive) { - if (amount.startsWith("+")) amount else "+$amount" - } else { - if (amount.startsWith("-")) amount else "-$amount" - } + val magnitude = amount.trimStart('+', '-') + return if (positive) "+$magnitude" else "-$magnitude" } private fun bindMainValue(state: Web3TransactionDetailState) { diff --git a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionHolder.kt b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionHolder.kt index ff7d4036e3..d29f43d9e7 100644 --- a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionHolder.kt +++ b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionHolder.kt @@ -67,12 +67,8 @@ class Web3TransactionHolder( fun formatAmountWithSign(amount: String?, positive: Boolean): String { if (amount.isNullOrEmpty()) return "N/A" - val formattedAmount = amount.numberFormat8() - return if (positive) { - if (formattedAmount.startsWith("+")) formattedAmount else "+$formattedAmount" - } else { - if (formattedAmount.startsWith("-")) formattedAmount else "-$formattedAmount" - } + val magnitude = amount.numberFormat8().trimStart('+', '-') + return if (positive) "+$magnitude" else "-$magnitude" } @SuppressLint("SetTextI18s") @@ -248,13 +244,14 @@ fun AmountList( @Composable fun AmountRow(amount: String, symbol: String, isSender: Boolean) { + val magnitude = amount.numberFormat8().trimStart('+', '-') Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End, modifier = Modifier.wrapContentWidth() ) { Text( - text = "${if (isSender) "-" else "+"}${amount.numberFormat8()}", + text = "${if (isSender) "-" else "+"}$magnitude", color = colorResource(id = if (isSender) R.color.wallet_pink else R.color.wallet_green), fontSize = 16.sp, fontFamily = FontFamily(Font(R.font.mixin_font)), diff --git a/app/src/test/java/one/mixin/android/repository/PendingTransactionAssetChangesTest.kt b/app/src/test/java/one/mixin/android/repository/PendingTransactionAssetChangesTest.kt new file mode 100644 index 0000000000..3c7b2f1516 --- /dev/null +++ b/app/src/test/java/one/mixin/android/repository/PendingTransactionAssetChangesTest.kt @@ -0,0 +1,79 @@ +package one.mixin.android.repository + +import one.mixin.android.api.response.web3.BalanceChange +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PendingTransactionAssetChangesTest { + @Test + fun swapPutsNegativeAmountsInSendersAndPositiveInReceivers() { + val changes = pendingAssetChanges( + listOf( + balanceChange(assetId = "usdt", amount = "5", from = "wallet", to = "wallet"), + balanceChange(assetId = "aeth-usdt", amount = "-4.999998", from = "wallet", to = null), + ), + ) + + assertEquals(listOf("aeth-usdt"), changes.senders.map { it.assetId }) + assertEquals(listOf("4.999998"), changes.senders.map { it.amount }) + assertEquals(listOf("usdt"), changes.receivers.map { it.assetId }) + assertEquals(listOf("5"), changes.receivers.map { it.amount }) + } + + @Test + fun solanaSwapDoesNotMirrorEachAsset() { + val changes = pendingAssetChanges( + listOf( + balanceChange(assetId = "usdc", amount = "10"), + balanceChange(assetId = "jiusdc", amount = "-9.446338"), + ), + ) + + assertEquals(1, changes.senders.size) + assertEquals(1, changes.receivers.size) + assertEquals("jiusdc", changes.senders.single().assetId) + assertEquals("9.446338", changes.senders.single().amount) + assertEquals("usdc", changes.receivers.single().assetId) + assertEquals("10", changes.receivers.single().amount) + } + + @Test + fun zeroAndInvalidAmountsAreSkipped() { + val changes = pendingAssetChanges( + listOf( + balanceChange(assetId = "zero", amount = "0"), + balanceChange(assetId = "blank", amount = ""), + balanceChange(assetId = "nan", amount = "abc"), + ), + ) + + assertTrue(changes.senders.isEmpty()) + assertTrue(changes.receivers.isEmpty()) + } + + @Test + fun nullBalanceChangesAreEmpty() { + val changes = pendingAssetChanges(null) + + assertTrue(changes.senders.isEmpty()) + assertTrue(changes.receivers.isEmpty()) + } + + private fun balanceChange( + assetId: String, + amount: String, + from: String? = "from", + to: String? = "to", + ) = BalanceChange( + assetId = assetId, + address = "asset-key", + amount = amount, + decimals = 6, + name = assetId, + symbol = assetId, + icon = null, + from = from, + to = to, + ) +} diff --git a/app/src/test/java/one/mixin/android/ui/common/BalanceChangePresentationTest.kt b/app/src/test/java/one/mixin/android/ui/common/BalanceChangePresentationTest.kt index 2dc4a1d6c2..2e0abc9169 100644 --- a/app/src/test/java/one/mixin/android/ui/common/BalanceChangePresentationTest.kt +++ b/app/src/test/java/one/mixin/android/ui/common/BalanceChangePresentationTest.kt @@ -30,4 +30,10 @@ class BalanceChangePresentationTest { balanceChangePresentation("+0"), ) } + + @Test + fun forcedSignDoesNotDuplicateExistingSign() { + assertEquals("+1.25", balanceChangePresentation("-1.25", isReceive = true).amount) + assertEquals("-1.25", balanceChangePresentation("+1.25", isReceive = false).amount) + } }