diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 636d7a8aa0..009cf6332a 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 636d7a8aa0e330e9b95e91d85f23ad15ac6d913f +Subproject commit 009cf6332a72cf0893167221abf7010d033906c2 diff --git a/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt index bc33cbeb88..04581dc15c 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt @@ -2,6 +2,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.networks.repository.NetworksRepository import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase @@ -41,11 +42,13 @@ internal object QrScanningDomainModule { multiAccountListSupplier: MultiAccountListSupplier, qrScanningEventsRepository: QrScanningEventsRepository, userWalletsListRepository: UserWalletsListRepository, + networksRepository: NetworksRepository, ): ResolveQrSendTargetsUseCase { return ResolveQrSendTargetsUseCase( multiAccountListSupplier = multiAccountListSupplier, qrScanningEventsRepository = qrScanningEventsRepository, userWalletsListRepository = userWalletsListRepository, + networksRepository = networksRepository, ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index 2efdd84d9e..ffca01be8a 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -51,9 +51,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds @Suppress("LongParameterList", "LargeClass") @HiltViewModel @@ -142,10 +140,7 @@ internal class MainViewModel @Inject constructor( viewModelScope.launch { launchAPIRequests { launch { blockchainSDKFactory.init() } - - launch { - withTimeout(timeMillis = 1.seconds.inWholeMilliseconds) { fetchUserCountry() } - } + launch { fetchUserCountry() } } subscribeToSelectedWallet() diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt index 1cedf7d5b3..c2ea0c7fe3 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt @@ -72,7 +72,12 @@ object NotificationsFactory { ) is GetFeeError.BlockchainErrors.SuiOneCoinRequired -> add(NotificationUM.Sui.NotEnoughCoinForTokenTransaction) - is GetFeeError.DataError -> when (feeError.cause) { + is GetFeeError.DataError -> when (val cause = feeError.cause) { + is BlockchainSdkError.Kaspa.DustChangeError -> add( + NotificationUM.Error.MinimumAmountError( + amount = cause.minimumAmount.format { crypto(tokenStatus.currency) }, + ), + ) BlockchainSdkError.TransactionDustChangeError -> add( NotificationUM.Error.MinimumAmountError( amount = dustValue.format { crypto(tokenStatus.currency) }, @@ -388,6 +393,11 @@ object NotificationsFactory { rentExemptionAmount = validationError.rentAmount, cryptoCurrency = cryptoCurrency, ) + is BlockchainSdkError.Kaspa.DustChangeError -> add( + NotificationUM.Error.MinimumAmountError( + amount = validationError.minimumAmount.format { crypto(cryptoCurrency) }, + ), + ) is BlockchainSdkError.TransactionDustChangeError -> add( NotificationUM.Error.MinimumAmountError( amount = dustValue.format { crypto(cryptoCurrency) }, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt index 884b8bfdae..3b3d6047d0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt @@ -125,7 +125,7 @@ fun Transaction(state: TransactionState, isBalanceHidden: Boolean, modifier: Mod top.linkTo(parent.top) bottom.linkTo(timestampItem.top) end.linkTo(parent.end) - width = Dimension.fillToConstraints + width = Dimension.wrapContent }, ) @@ -274,6 +274,8 @@ private fun Amount(state: TransactionState, isBalanceHidden: Boolean, modifier: } }, style = TangemTheme.typography.body2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } is TransactionState.Loading -> { diff --git a/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt b/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt index b0dc9c6af7..b5a2fb854c 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt @@ -57,7 +57,7 @@ internal class DefaultNetworksRepository( .map { currency -> CryptoCurrencyAddress( cryptoCurrency = currency, - address = getDefaultAddress(userWalletId, network), + address = getDefaultAddress(userWalletId, network).orEmpty(), ) } } @@ -74,11 +74,17 @@ internal class DefaultNetworksRepository( .map { currency -> CryptoCurrencyAddress( cryptoCurrency = currency, - address = getDefaultAddress(userWalletId, currency.network), + address = getDefaultAddress(userWalletId, currency.network).orEmpty(), ) } } + override suspend fun getDefaultAddress(userWalletId: UserWalletId, network: Network): String? { + return withContext(dispatchers.io) { + walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network) + } + } + override suspend fun hasCachedStatuses(userWalletId: UserWalletId): Boolean { return networksStatusesStore.contains(userWalletId) } @@ -100,10 +106,4 @@ internal class DefaultNetworksRepository( networksStatusesStore.storeStatus(userWalletId = userWalletId, status = networkStatus) } - - private suspend fun getDefaultAddress(userWalletId: UserWalletId, network: Network): String { - return withContext(dispatchers.io) { - walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network).orEmpty() - } - } } \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt index 62ff974e39..76bd17a9b1 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt @@ -3,6 +3,8 @@ package com.tangem.data.qrscanning.di import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser import com.tangem.data.qrscanning.parser.Eip681PaymentUriParser import com.tangem.data.qrscanning.parser.QrContentClassifierParser +import com.tangem.data.qrscanning.parser.SolanaPaymentUriParser +import com.tangem.data.qrscanning.parser.TronPaymentUriParser import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository import dagger.Module @@ -24,6 +26,8 @@ internal object QrScanningDataModule { blockchainDataProvider = blockchainDataProvider, paymentUriParsers = setOf( Eip681PaymentUriParser(blockchainDataProvider), + TronPaymentUriParser(blockchainDataProvider), + SolanaPaymentUriParser(blockchainDataProvider), Bip321PaymentUriParser(blockchainDataProvider), ), ), diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt index 2b8dc3085f..66a7d643a6 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt @@ -1,10 +1,13 @@ package com.tangem.data.qrscanning.parser +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.qrscanning.models.ClassifiedQrContent internal class Bip321PaymentUriParser( private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider, + private val helper: PaymentUriResolveHelper = PaymentUriResolveHelper(), ) : PaymentUriParser { override fun parse( @@ -12,52 +15,69 @@ internal class Bip321PaymentUriParser( coins: List, allCurrencies: List, ): PaymentUriParser.ParseResult { - val schemeAndRest = extractSchemeAndRest(qrCode, coins) + val (scheme, blockchains) = SCHEME_TO_BLOCKCHAINS.entries + .firstOrNull { (scheme, _) -> qrCode.startsWith(scheme, ignoreCase = true) } ?: return PaymentUriParser.ParseResult.NotRecognized - val (matchingCoins, withoutScheme) = schemeAndRest - val parsed = QrSentUriParser().parse(withoutScheme) + val parsed = helper.parseUri(qrCode, scheme) ?: return PaymentUriParser.ParseResult.RecognizedError( ClassifiedQrContent.Error.Unrecognized(qrCode), ) + val matchingCoins = coins.filter { it.network.toBlockchain() in blockchains } val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() val matchingCurrencies = allCurrencies.filter { it.network.id in matchingNetworkIds } + if (matchingCurrencies.isEmpty()) { return PaymentUriParser.ParseResult.RecognizedError( ClassifiedQrContent.Error.UnsupportedNetwork( raw = qrCode, - blockchain = matchingCoins.firstOrNull()?.network?.name, + blockchain = blockchains.first().fullName, ), ) } - return PaymentUriParser.ParseResult.Success( + val isAddressValid = matchingCoins.any { + blockchainDataProvider.validateAddress(it.network, parsed.address) + } + if (!isAddressValid) { + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.Unrecognized(qrCode), + ) + } + + val result = PaymentUriParser.ParseResult.Success( ClassifiedQrContent.PaymentUri( address = parsed.address, amount = parsed.amount, - memo = parsed.memo, + memo = parsed.memo?.second, matchingCurrencies = matchingCurrencies, ), ) + + val unconsumed = parsed.remainingParams - PARAM_LABEL + return helper.validateParams( + result = result, + unconsumedParams = unconsumed, + memo = parsed.memo, + matchingCoins = matchingCoins, + ) } - private fun extractSchemeAndRest( - qrCode: String, - coins: List, - ): Pair, String>? { - for (coin in coins) { - val schemes = blockchainDataProvider.getShareSchemes(coin.network) - for (scheme in schemes) { - if (qrCode.startsWith(scheme, ignoreCase = true)) { - val withoutScheme = qrCode.removeRange(0, scheme.length) - val allMatchingCoins = coins.filter { c -> - blockchainDataProvider.getShareSchemes(c.network).any { it.equals(scheme, ignoreCase = true) } - } - return allMatchingCoins to withoutScheme - } - } - } - return null + private companion object { + const val PARAM_LABEL = "label" + val BLOCKCHAINS = listOf( + Blockchain.Bitcoin, + Blockchain.BitcoinTestnet, + Blockchain.Litecoin, + Blockchain.Binance, + Blockchain.BinanceTestnet, + Blockchain.Dogecoin, + Blockchain.XRP, + ) + val SCHEME_TO_BLOCKCHAINS: Map> = BLOCKCHAINS + .flatMap { blockchain -> blockchain.getShareScheme().map { scheme -> scheme to blockchain } } + .groupBy({ it.first }, { it.second }) + .mapValues { it.value.toSet() } } } \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt index 2d109a40c8..69e2c408b8 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt @@ -1,5 +1,6 @@ package com.tangem.data.qrscanning.parser +import com.tangem.blockchain.common.Blockchain import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.qrscanning.models.ClassifiedQrContent import java.math.BigDecimal @@ -14,11 +15,10 @@ internal class Eip681PaymentUriParser( coins: List, allCurrencies: List, ): PaymentUriParser.ParseResult { - if (!qrCode.startsWith(SCHEME)) { - return PaymentUriParser.ParseResult.NotRecognized - } + val scheme = SCHEMES.find { qrCode.startsWith(it, ignoreCase = true) } + ?: return PaymentUriParser.ParseResult.NotRecognized - val withoutScheme = qrCode.removePrefix(SCHEME) + val withoutScheme = qrCode.removePrefix(scheme) val parsed = parseEip681(withoutScheme) ?: return PaymentUriParser.ParseResult.NotRecognized val matchingCoins = findMatchingCoins(parsed.chainId, coins) @@ -31,8 +31,26 @@ internal class Eip681PaymentUriParser( ) } + val isAddressValid = matchingCoins.any { coin -> + blockchainDataProvider.validateAddress(coin.network, parsed.targetAddress) + } + if (!isAddressValid) { + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.Unrecognized(qrCode), + ) + } + val result = if (parsed.functionName == FUNCTION_TRANSFER) { - if (PARAM_ADDRESS !in parsed.params) { + val recipient = parsed.params[Param.ADDRESS.key] + if (recipient == null) { + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.Unrecognized(qrCode), + ) + } + val isRecipientValid = matchingCoins.any { coin -> + blockchainDataProvider.validateAddress(coin.network, recipient) + } + if (!isRecipientValid) { return PaymentUriParser.ParseResult.RecognizedError( ClassifiedQrContent.Error.Unrecognized(qrCode), ) @@ -41,16 +59,30 @@ internal class Eip681PaymentUriParser( } else { resolveNativeTransfer(parsed, matchingCoins, allCurrencies) } - return if (result != null) { - PaymentUriParser.ParseResult.Success(result) - } else { - PaymentUriParser.ParseResult.RecognizedError( + if (result == null) { + return PaymentUriParser.ParseResult.RecognizedError( ClassifiedQrContent.Error.UnsupportedNetwork( raw = qrCode, blockchain = matchingCoins.firstOrNull()?.network?.name, ), ) } + + val unsupportedParams = findUnsupportedParams(parsed.params, parsed.functionName) + return if (unsupportedParams.isEmpty()) { + PaymentUriParser.ParseResult.Success(result) + } else { + PaymentUriParser.ParseResult.SuccessWithWarning(result, unsupportedParams) + } + } + + private fun findUnsupportedParams(params: Map, functionName: String?): Map { + val supportedKeys = if (functionName == FUNCTION_TRANSFER) { + Param.transferParams() + } else { + Param.nativeParams() + } + return params.filterKeys { key -> key !in supportedKeys } } private fun resolveNativeTransfer( @@ -58,7 +90,7 @@ internal class Eip681PaymentUriParser( matchingCoins: List, allCurrencies: List, ): ClassifiedQrContent.PaymentUri? { - val valueWei = parsed.params[PARAM_VALUE]?.toBigDecimalOrNull() + val valueWei = parsed.params[Param.VALUE.key]?.toBigDecimalOrNull() if (matchingCoins.isEmpty()) return null @@ -87,7 +119,7 @@ internal class Eip681PaymentUriParser( matchingCoins: List, allCurrencies: List, ): ClassifiedQrContent.PaymentUri? { - val recipient = parsed.params[PARAM_ADDRESS] ?: return null + val recipient = parsed.params[Param.ADDRESS.key] ?: return null val contractAddress = parsed.targetAddress val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() @@ -100,7 +132,7 @@ internal class Eip681PaymentUriParser( if (matchingTokens.isEmpty()) return null - val rawAmount = parsed.params[PARAM_UINT256]?.toBigDecimalOrNull() + val rawAmount = parsed.params[Param.UINT256.key]?.toBigDecimalOrNull() val amount = rawAmount?.fromSmallestUnit(matchingTokens.first().decimals) return ClassifiedQrContent.PaymentUri( @@ -114,7 +146,7 @@ internal class Eip681PaymentUriParser( private fun findMatchingCoins(chainId: Long?, coins: List): List { if (chainId == null) { return coins.filter { coin -> - blockchainDataProvider.getShareSchemes(coin.network).any { it.startsWith(SCHEME) } + blockchainDataProvider.getChainId(coin.network) != null } } return coins.filter { coin -> @@ -131,7 +163,7 @@ internal class Eip681PaymentUriParser( val queryString = match.groupValues[GROUP_QUERY] val params = parseQueryParams(queryString) - val chainId = pathChainId ?: params[PARAM_CHAIN_ID]?.toLongOrNull() + val chainId = pathChainId ?: params[Param.CHAIN_ID.key]?.toLongOrNull() return Eip681Result( targetAddress = targetAddress, @@ -161,15 +193,27 @@ internal class Eip681PaymentUriParser( val params: Map, ) + private enum class Param(val key: String) { + VALUE("value"), + ADDRESS("address"), + UINT256("uint256"), + CHAIN_ID("chainId"), + ; + + companion object { + /** Params supported for native transfers (no function or unknown function). */ + fun nativeParams(): Set = setOf(VALUE.key, CHAIN_ID.key) + + /** Params supported for ERC-20 transfer() calls. */ + fun transferParams(): Set = setOf(ADDRESS.key, UINT256.key, CHAIN_ID.key) + } + } + private companion object { // ethereum:
[@][/][?] val URI_REGEX = Regex("""^([^@/?]+)(?:@(\d+))?(?:/([^?]+))?(?:\?(.+))?$""") - const val SCHEME = "ethereum:" + val SCHEMES = Blockchain.Ethereum.getShareScheme() const val FUNCTION_TRANSFER = "transfer" - const val PARAM_VALUE = "value" - const val PARAM_ADDRESS = "address" - const val PARAM_UINT256 = "uint256" - const val PARAM_CHAIN_ID = "chainId" const val GROUP_ADDRESS = 1 const val GROUP_CHAIN_ID = 2 const val GROUP_FUNCTION = 3 diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriParser.kt index e7a64b8740..a74168e99b 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriParser.kt @@ -16,5 +16,11 @@ internal interface PaymentUriParser { /** Successfully parsed with matching currencies. */ data class Success(val content: ClassifiedQrContent.PaymentUri) : ParseResult() + + /** Successfully parsed but QR contains unsupported parameters. */ + data class SuccessWithWarning( + val content: ClassifiedQrContent.PaymentUri, + val unsupportedParams: Map, + ) : ParseResult() } } \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriResolveHelper.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriResolveHelper.kt new file mode 100644 index 0000000000..3df547b4b1 --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriResolveHelper.kt @@ -0,0 +1,112 @@ +package com.tangem.data.qrscanning.parser + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import java.math.BigDecimal + +internal class PaymentUriResolveHelper { + + fun parseUri(qrCode: String, scheme: String): QrSentUriParser.Result? { + val withoutScheme = qrCode.removeRange(0, scheme.length) + return QrSentUriParser().parse(withoutScheme) + } + + fun resolveTokenTransfer( + context: ResolveContext, + contractAddress: String, + interpretAmount: (BigDecimal, Int) -> BigDecimal = { raw, _ -> raw }, + ): PaymentUriParser.ParseResult { + val matchingTokens = context.allCurrencies.filterIsInstance() + .filter { token -> + token.network.id in context.matchingNetworkIds && + token.contractAddress.equals(contractAddress, ignoreCase = true) + } + + if (matchingTokens.isEmpty()) { + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.UnsupportedNetwork( + raw = context.qrCode, + blockchain = context.blockchainName, + ), + ) + } + + val amount = context.parsed.amount?.let { + interpretAmount(it, matchingTokens.first().decimals) + } + + return PaymentUriParser.ParseResult.Success( + ClassifiedQrContent.PaymentUri( + address = context.parsed.address, + amount = amount, + memo = context.parsed.memo?.second, + matchingCurrencies = matchingTokens, + ), + ) + } + + fun resolveNativeOrAll( + context: ResolveContext, + interpretAmount: (BigDecimal, Int) -> BigDecimal = { raw, _ -> raw }, + ): PaymentUriParser.ParseResult { + val decimals = context.matchingCoins.firstOrNull()?.decimals + val amount = if (decimals != null) { + context.parsed.amount?.let { interpretAmount(it, decimals) } + } else { + context.parsed.amount + } + + val matchingCurrencies = if (context.parsed.amount != null) { + context.matchingCoins + } else { + context.allCurrencies.filter { it.network.id in context.matchingNetworkIds } + } + + return PaymentUriParser.ParseResult.Success( + ClassifiedQrContent.PaymentUri( + address = context.parsed.address, + amount = amount, + memo = context.parsed.memo?.second, + matchingCurrencies = matchingCurrencies, + ), + ) + } + + fun validateParams( + result: PaymentUriParser.ParseResult, + unconsumedParams: Map, + memo: Pair?, + matchingCoins: List, + ): PaymentUriParser.ParseResult { + if (result !is PaymentUriParser.ParseResult.Success) return result + + val unsupported = buildMap { + putAll(unconsumedParams) + if (memo != null && !isMemoSupported(matchingCoins)) { + put(memo.first, memo.second) + } + } + + return if (unsupported.isEmpty()) { + result + } else { + PaymentUriParser.ParseResult.SuccessWithWarning(result.content, unsupported) + } + } + + private fun isMemoSupported(matchingCoins: List): Boolean { + return matchingCoins.any { + it.network.transactionExtrasType != Network.TransactionExtrasType.NONE + } + } + + data class ResolveContext( + val parsed: QrSentUriParser.Result, + val matchingCoins: List, + val matchingNetworkIds: Set, + val allCurrencies: List, + val blockchainName: String, + val qrCode: String, + ) +} \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt index dc0ef5a3db..ca6fed1fc2 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt @@ -26,6 +26,12 @@ internal class QrContentClassifierParser( when (val paymentUriResult = tryParsePaymentUri(qrCode, uniqueCoins, userCurrencies)) { is PaymentUriParser.ParseResult.Success -> return paymentUriResult.content + is PaymentUriParser.ParseResult.SuccessWithWarning -> { + return ClassifiedQrContent.PaymentUriWarning( + paymentUri = paymentUriResult.content, + unsupportedParams = paymentUriResult.unsupportedParams, + ) + } is PaymentUriParser.ParseResult.RecognizedError -> return paymentUriResult.error is PaymentUriParser.ParseResult.NotRecognized -> Unit } @@ -77,7 +83,6 @@ internal class QrContentClassifierParser( } internal interface BlockchainDataProvider { - fun getShareSchemes(network: Network): List fun validateAddress(network: Network, address: String): Boolean fun getChainId(network: Network): Long? fun findSupportedBlockchainName(address: String): String? @@ -85,10 +90,6 @@ internal class QrContentClassifierParser( } internal class DefaultBlockchainDataProvider : BlockchainDataProvider { - override fun getShareSchemes(network: Network): List { - return runCatching { network.toBlockchain().getShareScheme() }.getOrDefault(emptyList()) - } - override fun validateAddress(network: Network, address: String): Boolean { return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false) } diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt index ea80fb564d..2655f9ee3b 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt @@ -8,8 +8,8 @@ internal class QrSentUriParser { data class Result( val address: String, val amount: BigDecimal?, - val memo: String?, - val params: Map, + val memo: Pair?, + val remainingParams: Map, ) fun parse(withoutScheme: String): Result? { @@ -20,15 +20,24 @@ internal class QrSentUriParser { val params = extractParameters(withoutScheme) val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull() - val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let { - runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it) + + val memoKey = MemoParam.keys.firstOrNull { it in params } + val memo = memoKey?.let { key -> + val raw = params[key] ?: return@let null + val decoded = runCatching { URLDecoder.decode(raw, CHARSET_UTF8) }.getOrDefault(raw) + key to decoded + } + + val consumedKeys = buildSet { + add(PARAM_AMOUNT) + addAll(MemoParam.keys) } return Result( address = address, amount = amount, memo = memo, - params = params, + remainingParams = params - consumedKeys, ) } @@ -44,6 +53,17 @@ internal class QrSentUriParser { .toMap() } + enum class MemoParam(val key: String) { + MEMO("memo"), + MESSAGE("message"), + DESTINATION_TAG("dt"), + ; + + companion object { + val keys = entries.map { it.key }.toSet() + } + } + companion object { const val CHAIN_DELIMITER = '@' const val FUNCTION_DELIMITER = '/' @@ -51,8 +71,6 @@ internal class QrSentUriParser { const val PARAMS_DELIMITER = '&' const val PARAM_VALUE_DELIMITER = '=' const val PARAM_AMOUNT = "amount" - const val PARAM_MEMO = "memo" - const val PARAM_MESSAGE = "message" const val PARAM_ADDRESS = "address" const val PARAM_VALUE = "value" const val PARAM_UINT256 = "uint256" diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt new file mode 100644 index 0000000000..cd9cee1f8c --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt @@ -0,0 +1,73 @@ +package com.tangem.data.qrscanning.parser + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent + +internal class SolanaPaymentUriParser( + private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider, + private val helper: PaymentUriResolveHelper = PaymentUriResolveHelper(), +) : PaymentUriParser { + + override fun parse( + qrCode: String, + coins: List, + allCurrencies: List, + ): PaymentUriParser.ParseResult { + val scheme = SCHEMES.find { qrCode.startsWith(it, ignoreCase = true) } + ?: return PaymentUriParser.ParseResult.NotRecognized + + val parsed = helper.parseUri(qrCode, scheme) + ?: return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.Unrecognized(qrCode), + ) + + val matchingCoins = coins.filter { it.network.toBlockchain() == BLOCKCHAIN } + if (matchingCoins.isEmpty()) { + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.UnsupportedNetwork(raw = qrCode, blockchain = BLOCKCHAIN.fullName), + ) + } + + val isAddressValid = matchingCoins.any { + blockchainDataProvider.validateAddress(it.network, parsed.address) + } + if (!isAddressValid) { + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.Unrecognized(qrCode), + ) + } + + val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() + val context = PaymentUriResolveHelper.ResolveContext( + parsed = parsed, + matchingCoins = matchingCoins, + matchingNetworkIds = matchingNetworkIds, + allCurrencies = allCurrencies, + blockchainName = BLOCKCHAIN.fullName, + qrCode = qrCode, + ) + + val splTokenMint = parsed.remainingParams[PARAM_SPL_TOKEN] + val result = if (splTokenMint != null) { + helper.resolveTokenTransfer(context, splTokenMint) + } else { + helper.resolveNativeOrAll(context) + } + + val unconsumed = parsed.remainingParams - PARAM_SPL_TOKEN + return helper.validateParams( + result = result, + unconsumedParams = unconsumed, + memo = parsed.memo, + matchingCoins = matchingCoins, + ) + } + + private companion object { + val BLOCKCHAIN = Blockchain.Solana + val SCHEMES = BLOCKCHAIN.getShareScheme() + const val PARAM_SPL_TOKEN = "spl-token" + } +} \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt new file mode 100644 index 0000000000..985e94855d --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt @@ -0,0 +1,91 @@ +package com.tangem.data.qrscanning.parser + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import java.math.BigDecimal +import java.math.MathContext + +internal class TronPaymentUriParser( + private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider, + private val helper: PaymentUriResolveHelper = PaymentUriResolveHelper(), +) : PaymentUriParser { + + override fun parse( + qrCode: String, + coins: List, + allCurrencies: List, + ): PaymentUriParser.ParseResult { + val scheme = SCHEMES.find { qrCode.startsWith(it, ignoreCase = true) } + ?: return PaymentUriParser.ParseResult.NotRecognized + + val parsed = helper.parseUri(qrCode, scheme) + ?: return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.Unrecognized(qrCode), + ) + + val matchingCoins = coins.filter { it.network.toBlockchain() == BLOCKCHAIN } + if (matchingCoins.isEmpty()) { + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.UnsupportedNetwork(raw = qrCode, blockchain = BLOCKCHAIN.fullName), + ) + } + + val isAddressValid = matchingCoins.any { + blockchainDataProvider.validateAddress(it.network, parsed.address) + } + if (!isAddressValid) { + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.Unrecognized(qrCode), + ) + } + + val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() + val context = PaymentUriResolveHelper.ResolveContext( + parsed = parsed, + matchingCoins = matchingCoins, + matchingNetworkIds = matchingNetworkIds, + allCurrencies = allCurrencies, + blockchainName = BLOCKCHAIN.fullName, + qrCode = qrCode, + ) + + val tokenContractAddress = parsed.remainingParams[PARAM_TOKEN] + val result = if (tokenContractAddress != null) { + helper.resolveTokenTransfer(context, tokenContractAddress, ::interpretAmount) + } else { + helper.resolveNativeOrAll(context, ::interpretAmount) + } + + val unconsumed = parsed.remainingParams - PARAM_TOKEN + return helper.validateParams( + result = result, + unconsumedParams = unconsumed, + memo = parsed.memo, + matchingCoins = matchingCoins, + ) + } + + /** + * Tron amount interpretation: + * - If the number contains a decimal point — use as-is + * - If no decimal point and <= [AMOUNT_THRESHOLD] — treat as a normal number (e.g. 100 = 100 TRX) + * - If no decimal point and > [AMOUNT_THRESHOLD] — treat as smallest unit, shift decimal left by [decimals] + */ + private fun interpretAmount(raw: BigDecimal, decimals: Int): BigDecimal { + val hasDecimalPoint = raw.scale() > 0 + return if (hasDecimalPoint || raw <= AMOUNT_THRESHOLD) { + raw + } else { + raw.divide(BigDecimal.TEN.pow(decimals), MathContext.DECIMAL128) + } + } + + private companion object { + val BLOCKCHAIN = Blockchain.Tron + val SCHEMES = BLOCKCHAIN.getShareScheme() + val AMOUNT_THRESHOLD = BigDecimal(100_000) + const val PARAM_TOKEN = "token" + } +} \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt index 33d6158669..8b71bc156a 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt @@ -47,11 +47,11 @@ internal class DefaultQrScanningEventsRepository( val result = QrResult(address = parsed.address) result.amount = parsed.amount - result.memo = parsed.memo + result.memo = parsed.memo?.second // ERC-681: if 'address' parameter exists, currency must be a token, // and the URI address must match the token's contract address. - parsed.params[QrSentUriParser.PARAM_ADDRESS]?.let { addressValue -> + parsed.remainingParams[QrSentUriParser.PARAM_ADDRESS]?.let { addressValue -> val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult() if (tokenCurrency.contractAddress.equals(parsed.address, ignoreCase = true)) { result.address = addressValue @@ -61,8 +61,8 @@ internal class DefaultQrScanningEventsRepository( } // ERC-681: value/uint256 is in the smallest unit, needs conversion - val valueStr = parsed.params[QrSentUriParser.PARAM_VALUE] - ?: parsed.params[QrSentUriParser.PARAM_UINT256] + val valueStr = parsed.remainingParams[QrSentUriParser.PARAM_VALUE] + ?: parsed.remainingParams[QrSentUriParser.PARAM_UINT256] if (valueStr != null) { result.amount = valueStr.parseBigDecimalOrNull() ?.toPlainString()?.toBigDecimalOrNull() diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt index 63a91fe88e..cac610a9e7 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt @@ -15,7 +15,7 @@ import java.math.BigDecimal internal class Bip321PaymentUriParserTest { private val blockchainDataProvider = mockk { - every { getShareSchemes(any()) } returns emptyList() + every { validateAddress(any(), any()) } returns true } private val parser = Bip321PaymentUriParser(blockchainDataProvider) @@ -23,8 +23,6 @@ internal class Bip321PaymentUriParserTest { @Test fun `bitcoin URI with address and amount`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") - val result = parser.parse( qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5", coins = listOf(bitcoinCoin), @@ -40,8 +38,6 @@ internal class Bip321PaymentUriParserTest { @Test fun `bitcoin URI with address only`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") - val result = parser.parse( qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", coins = listOf(bitcoinCoin), @@ -56,8 +52,6 @@ internal class Bip321PaymentUriParserTest { @Test fun `bitcoin URI with amount and message`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") - val result = parser.parse( qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.23&message=Donation", coins = listOf(bitcoinCoin), @@ -72,8 +66,6 @@ internal class Bip321PaymentUriParserTest { @Test fun `bitcoin URI with label and message`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") - val result = parser.parse( qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?label=Satoshi&message=Payment", coins = listOf(bitcoinCoin), @@ -90,8 +82,6 @@ internal class Bip321PaymentUriParserTest { @Test fun `litecoin URI matches litecoin coin`() { - every { blockchainDataProvider.getShareSchemes(litecoinCoin.network) } returns listOf("litecoin:") - val result = parser.parse( qrCode = "litecoin:LcHKx4Tt97hnGgR3CRUiB1gSQ3F8wMozLj?amount=10", coins = listOf(litecoinCoin), @@ -105,11 +95,21 @@ internal class Bip321PaymentUriParserTest { } @Test - fun `no matching scheme returns NotRecognized`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") - + fun `dogecoin URI matches dogecoin coin`() { val result = parser.parse( - qrCode = "dogecoin:DAddress?amount=100", + qrCode = "doge:DAddress?amount=100", + coins = listOf(dogecoinCoin), + allCurrencies = listOf(dogecoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("DAddress") + } + + @Test + fun `no matching scheme returns NotRecognized`() { + val result = parser.parse( + qrCode = "solana:SomeAddress?amount=100", coins = listOf(bitcoinCoin), allCurrencies = listOf(bitcoinCoin), ) @@ -118,22 +118,18 @@ internal class Bip321PaymentUriParserTest { } @Test - fun `ethereum scheme matches as Success`() { - every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:") - + fun `ethereum scheme returns NotRecognized`() { val result = parser.parse( qrCode = "ethereum:0xRecipient?value=1000", - coins = listOf(ethereumCoin), - allCurrencies = listOf(ethereumCoin), - ).asSuccess() + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ) - assertThat(result).isNotNull() + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) } @Test fun `case insensitive scheme matching`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") - val result = parser.parse( qrCode = "Bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1", coins = listOf(bitcoinCoin), @@ -146,13 +142,41 @@ internal class Bip321PaymentUriParserTest { // endregion + // region Unsupported network + + @Test + fun `invalid address returns Unrecognized error`() { + every { blockchainDataProvider.validateAddress(any(), eq("InvalidBtcAddress")) } returns false + + val result = parser.parse( + qrCode = "bitcoin:InvalidBtcAddress?amount=0.5", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + val error = (result as PaymentUriParser.ParseResult.RecognizedError).error + assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java) + } + + @Test + fun `bitcoin URI with no matching coin returns UnsupportedNetwork`() { + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5", + coins = listOf(litecoinCoin), + allCurrencies = listOf(litecoinCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + } + + // endregion + // region Includes tokens on matching network @Test fun `includes tokens on matching network`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") - - val btcToken = buildToken("bitcoin", "RUNE", "contractAddr") + val btcToken = buildToken("BTC", "RUNE", "contractAddr") val result = parser.parse( qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.01", @@ -191,17 +215,76 @@ internal class Bip321PaymentUriParserTest { } @Test - fun `bitcoin URI with memo param`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") - + fun `bitcoin URI with memo param returns warning for unsupported memo`() { val result = parser.parse( qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&memo=TestMemo", coins = listOf(bitcoinCoin), allCurrencies = listOf(bitcoinCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java) + val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning + assertThat(warning.content.memo).isEqualTo("TestMemo") + assertThat(warning.unsupportedParams).containsEntry("memo", "TestMemo") + } + + // endregion + + // region Unsupported params + + @Test + fun `unknown parameter returns SuccessWithWarning`() { + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5&req-unknownparam=bar", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java) + val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning + assertThat(warning.content.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(warning.unsupportedParams).containsEntry("req-unknownparam", "bar") + } + + @Test + fun `multiple unknown parameters all reported`() { + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&foo=1&bar=2", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java) + val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning + assertThat(warning.unsupportedParams).hasSize(2) + assertThat(warning.unsupportedParams).containsEntry("foo", "1") + assertThat(warning.unsupportedParams).containsEntry("bar", "2") + } + + @Test + fun `memo on network with memo support is not unsupported`() { + val xrpCoin = buildCoin("XRP", "XRP", "XRP", decimals = 6, extrasType = Network.TransactionExtrasType.DESTINATION_TAG) + + val result = parser.parse( + qrCode = "ripple:rAddress?dt=12345", + coins = listOf(xrpCoin), + allCurrencies = listOf(xrpCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.memo).isEqualTo("12345") + } + + @Test + fun `only known params returns Success`() { + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5&label=Satoshi", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), ).asSuccess() assertThat(result).isNotNull() - assertThat(result!!.memo).isEqualTo("TestMemo") + assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") } // endregion @@ -209,23 +292,33 @@ internal class Bip321PaymentUriParserTest { // region Helpers private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? { - return (this as? PaymentUriParser.ParseResult.Success)?.content + return when (this) { + is PaymentUriParser.ParseResult.Success -> content + is PaymentUriParser.ParseResult.SuccessWithWarning -> content + else -> null + } } - private val bitcoinCoin = buildCoin("bitcoin", decimals = 8) - private val litecoinCoin = buildCoin("litecoin", decimals = 8) - private val ethereumCoin = buildCoin("ethereum", decimals = 18) + private val bitcoinCoin = buildCoin("BTC", "Bitcoin", "BTC", decimals = 8) + private val litecoinCoin = buildCoin("LTC", "Litecoin", "LTC", decimals = 8) + private val dogecoinCoin = buildCoin("DOGE", "Dogecoin", "DOGE", decimals = 8) - private fun buildCoin(rawNetworkId: String, decimals: Int): CryptoCurrency.Coin { + private fun buildCoin( + rawNetworkId: String, + name: String, + symbol: String, + decimals: Int, + extrasType: Network.TransactionExtrasType = Network.TransactionExtrasType.NONE, + ): CryptoCurrency.Coin { return CryptoCurrency.Coin( id = CryptoCurrency.ID( prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId), ), - network = buildNetwork(rawNetworkId), - name = rawNetworkId, - symbol = rawNetworkId.take(3).uppercase(), + network = buildNetwork(rawNetworkId, name, symbol, extrasType), + name = name, + symbol = symbol, decimals = decimals, iconUrl = null, isCustom = false, @@ -239,7 +332,7 @@ internal class Bip321PaymentUriParserTest { body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress), ), - network = buildNetwork(rawNetworkId), + network = buildNetwork(rawNetworkId, rawNetworkId, symbol), name = symbol, symbol = symbol, decimals = 6, @@ -249,18 +342,23 @@ internal class Bip321PaymentUriParserTest { ) } - private fun buildNetwork(rawNetworkId: String): Network { + private fun buildNetwork( + rawNetworkId: String, + name: String, + symbol: String, + extrasType: Network.TransactionExtrasType = Network.TransactionExtrasType.NONE, + ): Network { return Network( id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), backendId = rawNetworkId, - name = rawNetworkId, - currencySymbol = rawNetworkId.take(3).uppercase(), + name = name, + currencySymbol = symbol, derivationPath = Network.DerivationPath.None, isTestnet = false, standardType = Network.StandardType.Unspecified("UNSPECIFIED"), hasFiatFeeRate = false, canHandleTokens = false, - transactionExtrasType = Network.TransactionExtrasType.NONE, + transactionExtrasType = extrasType, nameResolvingType = Network.NameResolvingType.NONE, ) } diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt index 6fca241974..a98cbe0026 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt @@ -15,9 +15,9 @@ import java.math.BigDecimal internal class Eip681PaymentUriParserTest { private val blockchainDataProvider = mockk { - every { getShareSchemes(any()) } returns emptyList() every { getChainId(any()) } returns null every { getBlockchainNameByChainId(any()) } returns null + every { validateAddress(any(), any()) } returns true } private val parser = Eip681PaymentUriParser(blockchainDataProvider) @@ -56,8 +56,8 @@ internal class Eip681PaymentUriParserTest { } @Test - fun `native transfer without chain_id falls back to scheme matching`() { - every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:") + fun `native transfer without chain_id falls back to chainId presence check`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L val result = parser.parse( qrCode = "ethereum:0xRecipient?value=1000000000000000000", @@ -174,6 +174,24 @@ internal class Eip681PaymentUriParserTest { assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java) } + @Test + fun `ERC-20 transfer with invalid recipient address returns Unrecognized error`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + every { blockchainDataProvider.validateAddress(any(), eq("0xInvalidRecipient")) } returns false + + val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + + val result = parser.parse( + qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xInvalidRecipient&uint256=1000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin, usdcToken), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + val error = (result as PaymentUriParser.ParseResult.RecognizedError).error + assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java) + } + // endregion @Test @@ -265,10 +283,64 @@ internal class Eip681PaymentUriParserTest { // endregion + // region Unsupported params + + @Test + fun `native transfer with unknown param returns SuccessWithWarning`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val result = parser.parse( + qrCode = "ethereum:0xRecipient@1?value=1000000000000000000&gasLimit=21000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java) + val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning + assertThat(warning.content.address).isEqualTo("0xRecipient") + assertThat(warning.unsupportedParams).containsEntry("gasLimit", "21000") + } + + @Test + fun `ERC-20 transfer with unknown param returns SuccessWithWarning`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + + val result = parser.parse( + qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xRecipient&uint256=1000000&gasPrice=20000000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin, usdcToken), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java) + val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning + assertThat(warning.unsupportedParams).containsEntry("gasPrice", "20000000000") + } + + @Test + fun `native transfer with only known params returns Success`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val result = parser.parse( + qrCode = "ethereum:0xRecipient@1?value=1000000000000000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.Success::class.java) + } + + // endregion + // region Helpers private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? { - return (this as? PaymentUriParser.ParseResult.Success)?.content + return when (this) { + is PaymentUriParser.ParseResult.Success -> content + is PaymentUriParser.ParseResult.SuccessWithWarning -> content + else -> null + } } private val bitcoinCoin = buildCoin("bitcoin", decimals = 8) diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt index 18f3572e4c..af88722a8d 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt @@ -14,7 +14,6 @@ import java.math.BigDecimal internal class QrContentClassifierTest { private val blockchainDataProvider = mockk { - every { getShareSchemes(any()) } returns emptyList() every { validateAddress(any(), any()) } returns false every { getChainId(any()) } returns null every { findSupportedBlockchainName(any()) } returns null diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt new file mode 100644 index 0000000000..068663caed --- /dev/null +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt @@ -0,0 +1,249 @@ +package com.tangem.data.qrscanning + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.qrscanning.parser.PaymentUriParser +import com.tangem.data.qrscanning.parser.QrContentClassifierParser +import com.tangem.data.qrscanning.parser.SolanaPaymentUriParser +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import java.math.BigDecimal + +internal class SolanaPaymentUriParserTest { + + private val blockchainDataProvider = mockk { + every { validateAddress(any(), any()) } returns true + } + private val parser = SolanaPaymentUriParser(blockchainDataProvider) + + // region Scheme matching + + @Test + fun `solana URI recognized`() { + val result = parser.parse( + qrCode = "solana:SolAddress", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("SolAddress") + } + + @Test + fun `non-solana URI returns NotRecognized`() { + val result = parser.parse( + qrCode = "bitcoin:1Address", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + @Test + fun `case insensitive scheme`() { + val result = parser.parse( + qrCode = "Solana:SolAddress", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin), + ).asSuccess() + + assertThat(result).isNotNull() + } + + // endregion + + // region Native transfer + + @Test + fun `native transfer with amount`() { + val result = parser.parse( + qrCode = "solana:SolAddress?amount=2.5", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin, usdcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.amount!!.compareTo(BigDecimal("2.5"))).isEqualTo(0) + assertThat(result.matchingCurrencies).containsExactly(solanaCoin) + } + + @Test + fun `no amount returns all currencies on network`() { + val result = parser.parse( + qrCode = "solana:SolAddress", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin, usdcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.amount).isNull() + assertThat(result.matchingCurrencies).containsExactly(solanaCoin, usdcToken) + } + + // endregion + + // region SPL token transfer + + @Test + fun `spl-token param resolves to matching token`() { + val result = parser.parse( + qrCode = "solana:SolAddress?spl-token=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&amount=10", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin, usdcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("SolAddress") + assertThat(result.amount!!.compareTo(BigDecimal("10"))).isEqualTo(0) + assertThat(result.matchingCurrencies).containsExactly(usdcToken) + } + + @Test + fun `spl-token not found returns UnsupportedNetwork`() { + val result = parser.parse( + qrCode = "solana:SolAddress?spl-token=UnknownMint", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + } + + // endregion + + // region Unsupported network + + @Test + fun `invalid address returns Unrecognized error`() { + every { blockchainDataProvider.validateAddress(any(), eq("InvalidSolAddress")) } returns false + + val result = parser.parse( + qrCode = "solana:InvalidSolAddress?amount=1", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + val error = (result as PaymentUriParser.ParseResult.RecognizedError).error + assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java) + } + + @Test + fun `no matching solana coin returns UnsupportedNetwork`() { + val result = parser.parse( + qrCode = "solana:SolAddress", + coins = emptyList(), + allCurrencies = emptyList(), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + } + + // endregion + + // region Unsupported params + + @Test + fun `unknown parameter returns SuccessWithWarning`() { + val result = parser.parse( + qrCode = "solana:SolAddress?amount=1&reference=abc123", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java) + val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning + assertThat(warning.content.address).isEqualTo("SolAddress") + assertThat(warning.unsupportedParams).containsEntry("reference", "abc123") + } + + @Test + fun `memo on non-memo network returns SuccessWithWarning`() { + val result = parser.parse( + qrCode = "solana:SolAddress?amount=1&memo=hello", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java) + val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning + assertThat(warning.unsupportedParams).containsEntry("memo", "hello") + } + + @Test + fun `only known params returns Success`() { + val result = parser.parse( + qrCode = "solana:SolAddress?amount=1", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.Success::class.java) + } + + // endregion + + // region Helpers + + private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? { + return when (this) { + is PaymentUriParser.ParseResult.Success -> content + is PaymentUriParser.ParseResult.SuccessWithWarning -> content + else -> null + } + } + + private val solanaNetwork = buildNetwork("SOLANA", "Solana", "SOL") + + private val solanaCoin = CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("SOLANA"), + suffix = CryptoCurrency.ID.Suffix.RawID("SOLANA"), + ), + network = solanaNetwork, + name = "Solana", + symbol = "SOL", + decimals = 9, + iconUrl = null, + isCustom = false, + ) + + private val usdcToken = CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("SOLANA"), + suffix = CryptoCurrency.ID.Suffix.RawID("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), + ), + network = solanaNetwork, + name = "USD Coin", + symbol = "USDC", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ) + + private fun buildNetwork(rawNetworkId: String, name: String, symbol: String): Network { + return Network( + id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), + backendId = rawNetworkId, + name = name, + currencySymbol = symbol, + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + // endregion +} \ No newline at end of file diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt new file mode 100644 index 0000000000..8c07742046 --- /dev/null +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt @@ -0,0 +1,289 @@ +package com.tangem.data.qrscanning + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.qrscanning.parser.PaymentUriParser +import com.tangem.data.qrscanning.parser.QrContentClassifierParser +import com.tangem.data.qrscanning.parser.TronPaymentUriParser +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import java.math.BigDecimal + +internal class TronPaymentUriParserTest { + + private val blockchainDataProvider = mockk { + every { validateAddress(any(), any()) } returns true + } + private val parser = TronPaymentUriParser(blockchainDataProvider) + + // region Scheme matching + + @Test + fun `tron URI recognized`() { + val result = parser.parse( + qrCode = "tron:TAddress", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("TAddress") + } + + @Test + fun `non-tron URI returns NotRecognized`() { + val result = parser.parse( + qrCode = "bitcoin:1Address", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + @Test + fun `case insensitive scheme`() { + val result = parser.parse( + qrCode = "Tron:TAddress", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ).asSuccess() + + assertThat(result).isNotNull() + } + + // endregion + + // region Native transfer + + @Test + fun `native transfer with amount`() { + val result = parser.parse( + qrCode = "tron:TAddress?amount=100", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin, usdtToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.amount!!.compareTo(BigDecimal("100"))).isEqualTo(0) + assertThat(result.matchingCurrencies).containsExactly(tronCoin) + } + + @Test + fun `no amount returns all currencies on network`() { + val result = parser.parse( + qrCode = "tron:TAddress", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin, usdtToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.amount).isNull() + assertThat(result.matchingCurrencies).containsExactly(tronCoin, usdtToken) + } + + // endregion + + // region Token transfer + + @Test + fun `token transfer with token param`() { + val result = parser.parse( + qrCode = "tron:TAddress?token=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t&amount=50", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin, usdtToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("TAddress") + assertThat(result.matchingCurrencies).containsExactly(usdtToken) + } + + @Test + fun `token not found returns UnsupportedNetwork`() { + val result = parser.parse( + qrCode = "tron:TAddress?token=TUnknownContract", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + } + + // endregion + + // region Amount interpretation + + @Test + fun `amount with decimal point used as-is`() { + val result = parser.parse( + qrCode = "tron:TAddress?amount=1.5", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.amount!!.compareTo(BigDecimal("1.5"))).isEqualTo(0) + } + + @Test + fun `integer amount less or equal 100000 treated as normal`() { + val result = parser.parse( + qrCode = "tron:TAddress?amount=100000", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.amount!!.compareTo(BigDecimal("100000"))).isEqualTo(0) + } + + @Test + fun `integer amount greater than 100000 treated as smallest unit`() { + // 1_000_000 with 6 decimals (TRX) = 1.0 + val result = parser.parse( + qrCode = "tron:TAddress?amount=1000000", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.amount!!.compareTo(BigDecimal("1"))).isEqualTo(0) + } + + // endregion + + // region Unsupported network + + @Test + fun `invalid address returns Unrecognized error`() { + every { blockchainDataProvider.validateAddress(any(), eq("InvalidTronAddress")) } returns false + + val result = parser.parse( + qrCode = "tron:InvalidTronAddress?amount=1", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + val error = (result as PaymentUriParser.ParseResult.RecognizedError).error + assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java) + } + + @Test + fun `no matching tron coin returns UnsupportedNetwork`() { + val result = parser.parse( + qrCode = "tron:TAddress", + coins = emptyList(), + allCurrencies = emptyList(), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + } + + // endregion + + // region Unsupported params + + @Test + fun `unknown parameter returns SuccessWithWarning`() { + val result = parser.parse( + qrCode = "tron:TAddress?amount=100&gasLimit=21000", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java) + val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning + assertThat(warning.content.address).isEqualTo("TAddress") + assertThat(warning.unsupportedParams).containsEntry("gaslimit", "21000") + } + + @Test + fun `memo on non-memo network returns SuccessWithWarning`() { + val result = parser.parse( + qrCode = "tron:TAddress?amount=100&memo=hello", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java) + val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning + assertThat(warning.unsupportedParams).containsEntry("memo", "hello") + } + + @Test + fun `only known params returns Success`() { + val result = parser.parse( + qrCode = "tron:TAddress?amount=100", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.Success::class.java) + } + + // endregion + + // region Helpers + + private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? { + return when (this) { + is PaymentUriParser.ParseResult.Success -> content + is PaymentUriParser.ParseResult.SuccessWithWarning -> content + else -> null + } + } + + private val tronNetwork = buildNetwork("TRON", "Tron", "TRX") + + private val tronCoin = CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("TRON"), + suffix = CryptoCurrency.ID.Suffix.RawID("TRON"), + ), + network = tronNetwork, + name = "Tron", + symbol = "TRX", + decimals = 6, + iconUrl = null, + isCustom = false, + ) + + private val usdtToken = CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("TRON"), + suffix = CryptoCurrency.ID.Suffix.RawID("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"), + ), + network = tronNetwork, + name = "Tether USD", + symbol = "USDT", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + ) + + private fun buildNetwork(rawNetworkId: String, name: String, symbol: String): Network { + return Network( + id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), + backendId = rawNetworkId, + name = name, + currencySymbol = symbol, + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + // endregion +} \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt index fbea44938c..8c0081e165 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt @@ -284,7 +284,7 @@ class GetAccountCurrencyStatusUseCaseTest { coEvery { supplier(supplierParams) } returns flowOf(accountStatusList) // Act - val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) + val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = currency.network) .let(::getEmittedValues) // Assert diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/repository/NetworksRepository.kt b/domain/networks/src/main/java/com/tangem/domain/networks/repository/NetworksRepository.kt index e27c6b2b47..f5641e9e9f 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/repository/NetworksRepository.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/repository/NetworksRepository.kt @@ -30,6 +30,11 @@ interface NetworksRepository { */ suspend fun getNetworkAddresses(userWalletId: UserWalletId, network: Network.RawID): List + /** + * Returns the default address for the given [network] in the selected [userWalletId] + */ + suspend fun getDefaultAddress(userWalletId: UserWalletId, network: Network): String? + /** Checks if there are cached statuses for given [userWalletId] */ suspend fun hasCachedStatuses(userWalletId: UserWalletId): Boolean } \ No newline at end of file diff --git a/domain/promo/src/main/java/com/tangem/domain/promo/GetStoryContentUseCase.kt b/domain/promo/src/main/java/com/tangem/domain/promo/GetStoryContentUseCase.kt index 03a93bc23a..c1f4686134 100644 --- a/domain/promo/src/main/java/com/tangem/domain/promo/GetStoryContentUseCase.kt +++ b/domain/promo/src/main/java/com/tangem/domain/promo/GetStoryContentUseCase.kt @@ -7,6 +7,7 @@ import com.tangem.domain.promo.models.StoryContent import com.tangem.domain.promo.models.StoryContentIds import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* import kotlin.time.Duration.Companion.seconds @@ -39,12 +40,14 @@ class GetStoryContentUseCase( } } + @OptIn(FlowPreview::class) private fun isFCAAllowed(id: String): Flow { return if (id == StoryContentIds.STORY_FIRST_TIME_SWAP.id) { settingsRepository.getUserCountryCode() .filterNotNull() - .timeout(5.seconds) + .timeout(3.seconds) .map { !it.needApplyFCARestrictions() } + .catch { emit(true) } } else { flowOf(true) } diff --git a/domain/qr-scanning/build.gradle.kts b/domain/qr-scanning/build.gradle.kts index 2679da7164..497c075d41 100644 --- a/domain/qr-scanning/build.gradle.kts +++ b/domain/qr-scanning/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { api(projects.domain.models) implementation(projects.domain.account) implementation(projects.domain.common) + implementation(projects.domain.networks) implementation(projects.domain.qrScanning.models) implementation(projects.domain.tokens.models) diff --git a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt index 1fefc8a555..b9b7b63ff6 100644 --- a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt @@ -19,6 +19,11 @@ sealed interface ClassifiedQrContent { val matchingCurrencies: List, ) : ClassifiedQrContent + data class PaymentUriWarning( + val paymentUri: PaymentUri, + val unsupportedParams: Map, + ) : ClassifiedQrContent + sealed interface Error : ClassifiedQrContent { /** QR code not recognized by any parser */ diff --git a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt index 7220a7ef95..986824f436 100644 --- a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt @@ -39,6 +39,13 @@ sealed class QrSendTarget { ) } + data object AddressSameAsWallet : QrSendTarget() + + data class Warning( + val target: QrSendTarget, + val unsupportedParams: Map, + ) : QrSendTarget() + data class WalletConnect(val uri: String) : QrSendTarget() data class Error(val error: ClassifiedQrContent.Error) : QrSendTarget() diff --git a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt index a33e20ec2d..4156a83e1f 100644 --- a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt +++ b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt @@ -5,17 +5,23 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.networks.repository.NetworksRepository import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import java.math.BigDecimal import com.tangem.domain.qrscanning.models.QrSendTarget +import kotlinx.coroutines.awaitAll class ResolveQrSendTargetsUseCase( private val multiAccountListSupplier: MultiAccountListSupplier, private val qrScanningEventsRepository: QrScanningEventsRepository, private val userWalletsListRepository: UserWalletsListRepository, + private val networksRepository: NetworksRepository, ) { suspend operator fun invoke(qrCode: String): QrSendTarget { @@ -49,7 +55,7 @@ class ResolveQrSendTargetsUseCase( return resolve(classified, portfolioIndex) } - private fun resolve(classified: ClassifiedQrContent, portfolioIndex: PortfolioIndex): QrSendTarget { + private suspend fun resolve(classified: ClassifiedQrContent, portfolioIndex: PortfolioIndex): QrSendTarget { return when (classified) { is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri) is ClassifiedQrContent.Error -> QrSendTarget.Error(classified) @@ -67,17 +73,29 @@ class ResolveQrSendTargetsUseCase( matchingCurrencies = classified.matchingCurrencies, portfolioIndex = portfolioIndex, ) + is ClassifiedQrContent.PaymentUriWarning -> { + val inner = resolve(classified.paymentUri, portfolioIndex) + QrSendTarget.Warning( + target = inner, + unsupportedParams = classified.unsupportedParams, + ) + } } } - private fun resolveAddressTarget( + private suspend fun resolveAddressTarget( address: String, amount: BigDecimal?, memo: String?, matchingCurrencies: List, portfolioIndex: PortfolioIndex, ): QrSendTarget { - val walletGroups = buildWalletGroups(matchingCurrencies, portfolioIndex) + val ownAddressNetworks = findOwnAddressNetworks(address, matchingCurrencies, portfolioIndex) + val walletGroups = buildWalletGroups(matchingCurrencies, portfolioIndex, ownAddressNetworks) + + if (walletGroups.isEmpty()) { + return QrSendTarget.AddressSameAsWallet + } val singleGroup = walletGroups.singleOrNull() val singleCurrency = singleGroup?.accounts?.singleOrNull()?.currencies?.singleOrNull() @@ -100,9 +118,33 @@ class ResolveQrSendTargetsUseCase( } } + private suspend fun findOwnAddressNetworks( + address: String, + matchingCurrencies: List, + portfolioIndex: PortfolioIndex, + ): Map> = coroutineScope { + matchingCurrencies.distinctBy { it.id } + .flatMap { currency -> + portfolioIndex.currencyLocations[currency.id].orEmpty().map { + it.accountId.userWalletId to currency.network + } + } + .distinct() + .map { (walletId, network) -> + async { + val ownAddress = networksRepository.getDefaultAddress(walletId, network) + if (ownAddress == address) walletId to network else null + } + } + .awaitAll() + .filterNotNull() + .groupBy(keySelector = { it.first }, valueTransform = { it.second.id }) + } + private fun buildWalletGroups( matchingCurrencies: List, portfolioIndex: PortfolioIndex, + ownAddressNetworks: Map>, ): List { val walletMap = linkedMapOf() val uniqueCurrencies = matchingCurrencies.distinctBy { it.id } @@ -110,13 +152,18 @@ class ResolveQrSendTargetsUseCase( for (currency in uniqueCurrencies) { val locations = portfolioIndex.currencyLocations[currency.id].orEmpty() for (location in locations) { - val walletInfo = walletMap.getOrPut(location.accountId.userWalletId) { - WalletInfo(location.walletName, linkedMapOf()) + val walletId = location.accountId.userWalletId + val isOwnAddress = ownAddressNetworks[walletId]?.contains(currency.network.id) == true + + if (!isOwnAddress) { + val walletInfo = walletMap.getOrPut(walletId) { + WalletInfo(location.walletName, linkedMapOf()) + } + val accountInfo = walletInfo.accounts.getOrPut(location.accountId) { + AccountInfo(location.accountName, mutableListOf()) + } + accountInfo.currencies.add(currency) } - val accountInfo = walletInfo.accounts.getOrPut(location.accountId) { - AccountInfo(location.accountName, mutableListOf()) - } - accountInfo.currencies.add(currency) } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 6cc3e25770..73e98e3506 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -83,7 +83,9 @@ import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.models.SwapAlertUM +import com.tangem.feature.swap.models.SwapCardState import com.tangem.feature.swap.models.SwapStateHolder +import com.tangem.feature.swap.models.TransactionCardType import com.tangem.feature.swap.models.UiActions import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.feature.swap.router.SwapNavScreen @@ -215,7 +217,7 @@ internal class SwapModel @Inject constructor( val feeSelectorRepository = FeeSelectorRepository() // shows currency order (direct - swap initial to selected, reversed = selected to initial) - private var isOrderReversed by mutableStateOf(false) + private val isOrderReversed = MutableStateFlow(value = params.isInitialReverseOrder) private val lastAmount = mutableStateOf(INITIAL_AMOUNT) private val lastReducedBalanceBy = mutableStateOf(BigDecimal.ZERO) private val swapRouter: SwapRouter = SwapRouter(router = router) @@ -427,20 +429,22 @@ internal class SwapModel @Inject constructor( selectedAccountCurrency?.status to selectedAccountCurrency?.account } - applyInitialTokenChoice( + val isApplied = applyInitialTokenChoice( state = state, selectedCurrency = selectedCurrency, selectedAccount = selectedAccount, isReverseFromTo = isReverseFromTo, ) - val fromCryptoCurrency = if (isOrderReversed) { - dataState.toCryptoCurrency - } else { - dataState.fromCryptoCurrency - } + // assume that fromCryptoCurrency selected according reverse flag, + // so update fee paid currency according to it + val fromCryptoCurrency = dataState.fromCryptoCurrency - if (fromCryptoCurrency != null) { + if (isApplied && fromCryptoCurrency != null) { + TangemLogger.i( + "updateFeePaidCryptoCurrencyFor: id = ${fromCryptoCurrency.currency.id}, " + + "isReverseFromTo: $isReverseFromTo", + ) updateFeePaidCryptoCurrencyFor(fromCryptoCurrency) } else { TangemLogger.e("updateFeePaidCryptoCurrencyFor failed: fromCryptoCurrency is null") @@ -498,7 +502,7 @@ internal class SwapModel @Inject constructor( state = state, selectedCurrency = selectedCurrency, selectedAccount = selectedAccount, - isReverseFromTo = isOrderReversed, + isReverseFromTo = isOrderReversed.value, ) subscribeToCoinBalanceUpdatesIfNeeded() @@ -527,29 +531,34 @@ internal class SwapModel @Inject constructor( } } + /** + * returns true if tokens are selected and dataState is updated, + * false if selected token is null and alert is shown with error message + */ private fun applyInitialTokenChoice( state: TokensDataStateExpress, selectedCurrency: CryptoCurrencyStatus?, selectedAccount: Account.CryptoPortfolio?, isReverseFromTo: Boolean, - ) { + ): Boolean { // exceptional case if (selectedCurrency == null) { + TangemLogger.e("No available tokens to swap for ${initialCurrencyFrom.symbol}") analyticsEventHandler.send(SwapEvents.NoticeNoAvailableTokensToSwap()) uiState = stateBuilder.createNoAvailableTokensToSwapState( uiStateHolder = uiState, fromToken = initialFromStatus, ) - return + return false } - isOrderReversed = isReverseFromTo - val (fromCurrencyStatus, toCurrencyStatus) = if (isOrderReversed) { + isOrderReversed.value = isReverseFromTo + val (fromCurrencyStatus, toCurrencyStatus) = if (isOrderReversed.value) { selectedCurrency to initialFromStatus } else { initialFromStatus to selectedCurrency } val (fromAccount, toAccount) = if (canUseFromAccountCurrencyStatus) { - if (isOrderReversed) { + if (isOrderReversed.value) { selectedAccount to requireNotNull(fromAccountCurrencyStatus).account } else { requireNotNull(fromAccountCurrencyStatus).account to selectedAccount @@ -573,7 +582,7 @@ internal class SwapModel @Inject constructor( toAccount = toAccount, ) ) { - return + return true } startLoadingQuotes( @@ -585,10 +594,11 @@ internal class SwapModel @Inject constructor( reduceBalanceBy = lastReducedBalanceBy.value, toProvidersList = findSwapProviders(fromCurrencyStatus, toCurrencyStatus), ) + return true } private fun updateTokensState(tokenDataState: TokensDataStateExpress) { - val tokensDataState = if (isOrderReversed) tokenDataState.fromGroup else tokenDataState.toGroup + val tokensDataState = if (isOrderReversed.value) tokenDataState.fromGroup else tokenDataState.toGroup chooseTokenBridge.updateCurrenciesGroup(tokensDataState) } @@ -1162,7 +1172,7 @@ internal class SwapModel @Inject constructor( private fun onSearchEntered(searchQuery: String) { val tokenDataState = dataState.tokensDataState ?: return - val group = if (isOrderReversed) { + val group = if (isOrderReversed.value) { tokenDataState.fromGroup } else { tokenDataState.toGroup @@ -1192,7 +1202,7 @@ internal class SwapModel @Inject constructor( ) } - val filteredTokenDataState = if (isOrderReversed) { + val filteredTokenDataState = if (isOrderReversed.value) { tokenDataState.copy( fromGroup = tokenDataState.fromGroup.copy( available = available, @@ -1238,7 +1248,7 @@ internal class SwapModel @Inject constructor( val fromAccount: Account.CryptoPortfolio? val toToken: CryptoCurrencyStatus val toAccount: Account.CryptoPortfolio? - if (isOrderReversed) { + if (isOrderReversed.value) { fromToken = foundToken fromAccount = foundAccount toToken = initialFromStatus @@ -1295,6 +1305,17 @@ internal class SwapModel @Inject constructor( return } modelScope.launch { + TangemLogger.i( + "updateFeePaidCryptoCurrencyFor: id = ${fromToken.currency.id}, " + + "isOrderReversed: ${isOrderReversed.value}", + ) + if ((uiState.sendCardData as? SwapCardState.SwapCardData)?.type is TransactionCardType.ReadOnly) { + uiState = stateBuilder.createInitialLoadingState( + initialCurrencyFrom = fromToken.currency, + initialCurrencyTo = toToken.currency, + fromNetworkInfo = fromToken.currency.getNetworkInfo(), + ) + } updateFeePaidCryptoCurrencyFor(fromToken) startLoadingQuotes( fromToken = fromToken, @@ -1314,7 +1335,7 @@ internal class SwapModel @Inject constructor( tokens: TokensDataStateExpress, id: String, ): Pair { - val accountCryptoCurrencyStatus = if (isOrderReversed) { + val accountCryptoCurrencyStatus = if (isOrderReversed.value) { tokens.fromGroup } else { tokens.toGroup @@ -1346,7 +1367,23 @@ internal class SwapModel @Inject constructor( feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( userWalletId = userWalletId, cryptoCurrencyStatus = currencyStatus, - ).getOrNull() ?: currencyStatus, + ) + .onLeft { + TangemLogger.e( + "Coin balance: Unable to get fee paid crypto currency status for " + + "${currencyStatus.currency.id}", + ) + } + .onRight { status -> + if (status == null) { + TangemLogger.e( + "Coin balance: Fee paid crypto currency status is null " + + "for ${currencyStatus.currency.id}", + ) + } + } + .getOrNull() + ?: currencyStatus, ) } @@ -1392,7 +1429,11 @@ internal class SwapModel @Inject constructor( toCryptoCurrency = newToToken, toAccount = newToAccount, ) - isOrderReversed = !isOrderReversed + isOrderReversed.value = !isOrderReversed.value + TangemLogger.i( + "updateFeePaidCryptoCurrencyFor: id = ${newFromToken.currency.id}, " + + "isOrderReversed: ${isOrderReversed.value}", + ) updateFeePaidCryptoCurrencyFor(newFromToken) dataState.tokensDataState?.let { tokensDataState -> updateTokensState(tokensDataState) @@ -1824,13 +1865,13 @@ internal class SwapModel @Inject constructor( } private fun findSwapProviders(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): List { - val groupToFind = if (isOrderReversed) { + val groupToFind = if (isOrderReversed.value) { dataState.tokensDataState?.fromGroup } else { dataState.tokensDataState?.toGroup } ?: return emptyList() - val idToFind = if (isOrderReversed) { + val idToFind = if (isOrderReversed.value) { fromToken.currency.id.value } else { toToken.currency.id.value @@ -1856,8 +1897,8 @@ internal class SwapModel @Inject constructor( fromAccount: Account.CryptoPortfolio?, toAccount: Account.CryptoPortfolio?, ): Boolean { - val selectedCurrency = if (isOrderReversed) fromToken else toToken - if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed)) return false + val selectedCurrency = if (isOrderReversed.value) fromToken else toToken + if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed.value)) return false analyticsEventHandler.send( SwapEvents.NoticeUnavailableToSwapPair( @@ -1867,6 +1908,14 @@ internal class SwapModel @Inject constructor( receiveBlockchain = toToken.currency.network.name, ), ) + // Cancel periodic quote task if selected token is not supported + singleTaskScheduler.cancelTask() + // Reset data state + dataState = SwapProcessDataState( + tokensDataState = dataState.tokensDataState, + ) + lastReducedBalanceBy.value = BigDecimal.ZERO + lastAmount.value = INITIAL_AMOUNT uiState = stateBuilder.createSwapNotSupportedState( uiStateHolder = uiState, fromToken = fromToken, @@ -1917,13 +1966,13 @@ internal class SwapModel @Inject constructor( val from = dataState.fromCryptoCurrency ?: return false val to = dataState.toCryptoCurrency ?: return false - val currenciesGroup = if (isOrderReversed) { + val currenciesGroup = if (isOrderReversed.value) { dataState.tokensDataState?.toGroup } else { dataState.tokensDataState?.fromGroup } ?: return false - val chosen = if (isOrderReversed) from else to + val chosen = if (isOrderReversed.value) from else to return currenciesGroup.accountCurrencyList.flatMap { accountSwapAvailability -> accountSwapAvailability.currencyList.map { accountSwapCurrency -> diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index b6613eba32..1f11ab7fa1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -183,10 +183,12 @@ internal class StateBuilder( if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( sendCardData = SwapCardState.SwapCardData( - type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable).copy( + type = TransactionCardType.ReadOnly( accountTitleUM = getFromCardAccountTitle(fromAccount), ), - amountTextFieldValue = null, + amountTextFieldValue = TextFieldValue( + text = "0", + ), amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), token = fromToken, tokenIconUrl = fromToken.currency.iconUrl, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index d304d3fe84..0c5b95efcd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -771,7 +771,12 @@ internal class WalletModel @Inject constructor( } private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) { - when (val target = resolveQrSendTargetsUseCase(qrCode)) { + val target = resolveQrSendTargetsUseCase(qrCode) + handleQrTarget(target, resultSource) + } + + private fun handleQrTarget(target: QrSendTarget, resultSource: QrResultSource) { + when (target) { is QrSendTarget.WalletConnect -> { val source = when (resultSource) { QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD @@ -801,6 +806,17 @@ internal class WalletModel @Inject constructor( is QrSendTarget.Multiple -> { innerWalletRouter.openNetworkSelectionBottomSheet(target) } + is QrSendTarget.AddressSameAsWallet -> { + uiMessageSender.send(WalletAlertUM.qrCodeAddressSameAsWallet()) + } + is QrSendTarget.Warning -> { + uiMessageSender.send( + WalletAlertUM.qrCodeUnsupportedParams( + unsupportedParams = target.unsupportedParams, + onContinue = { handleQrTarget(target.target, resultSource) }, + ), + ) + } is QrSendTarget.Error -> handleQrError(target.error) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt index 5668d743d8..a752af09d7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt @@ -211,6 +211,7 @@ internal object WalletScreenPreviewData { isHidingMode = false, showMarketsOnboarding = false, onDismissMarketsTooltip = {}, + isRedesignEnabled = true, ) val defaultAccountState = defaultState.copy( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt index aa268b3051..c52c371251 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt @@ -248,6 +248,7 @@ internal object WalletScreenPreviewDataLegacy { isHidingMode = false, showMarketsOnboarding = false, onDismissMarketsTooltip = {}, + isRedesignEnabled = false, ) internal val accountScreenState = diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt index 4313751a77..045a9c47bf 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt @@ -19,6 +19,7 @@ internal class MultiWalletContentLoader @AssistedInject constructor( private val multiWalletActionButtonsSubscriberFactory: MultiWalletActionButtonsSubscriber.Factory, private val tangemPayMainSubscriberFactory: TangemPayMainSubscriber.Factory, private val tokenSyncSubscriberFactory: TokenSyncSubscriber.Factory, + private val tokenListAnalyticsSubscriberFactory: TokenListAnalyticsSubscriber.Factory, private val designFeatureToggles: DesignFeatureToggles, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : WalletContentLoader(id = userWallet.walletId) { @@ -34,6 +35,7 @@ internal class MultiWalletContentLoader @AssistedInject constructor( }, multiWalletActionButtonsSubscriberFactory.create(userWallet), tangemPayMainSubscriberFactory.create(userWallet), + tokenListAnalyticsSubscriberFactory.create(userWallet), if (hotWalletFeatureToggles.isTokenSyncEnabled && userWallet is UserWallet.Hot) { tokenSyncSubscriberFactory.create(userWallet) } else { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt index 092521153e..d56549b7fd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt @@ -139,6 +139,7 @@ internal class WalletStateController @Inject constructor( isHidingMode = false, showMarketsOnboarding = false, onDismissMarketsTooltip = {}, + isRedesignEnabled = designFeatureToggles.isRedesignEnabled, ) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletAlertUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletAlertUM.kt index c63587fb17..ac56278723 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletAlertUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletAlertUM.kt @@ -76,6 +76,30 @@ internal object WalletAlertUM { ) } + fun qrCodeUnsupportedParams(unsupportedParams: Map, onContinue: () -> Unit): DialogMessage { + val paramsList = unsupportedParams.entries.joinToString { "${it.key} = ${it.value}" } + return DialogMessage( + title = resourceReference(R.string.qr_scanner_warning_unknown_parameters_title), + message = resourceReference( + id = R.string.qr_scanner_warning_unknown_parameters_message, + formatArgs = WrappedList(listOf(paramsList)), + ), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_continue), + onClick = onContinue, + ) + }, + secondActionBuilder = { cancelAction() }, + ) + } + + fun qrCodeAddressSameAsWallet(): DialogMessage { + return DialogMessage( + message = resourceReference(R.string.send_error_address_same_as_wallet), + ) + } + fun confirmExpressStatusHide(onConfirmClick: () -> Unit): DialogMessage { return DialogMessage( title = resourceReference(R.string.express_status_hide_dialog_title), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt index fe7cb111e8..9a0e1d8822 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt @@ -15,4 +15,5 @@ internal data class WalletScreenState( val isHidingMode: Boolean, val showMarketsOnboarding: Boolean, val onDismissMarketsTooltip: () -> Unit, + val isRedesignEnabled: Boolean, ) \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/WalletStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/WalletStateTransformer.kt index 5aa3c34900..2790e44084 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/WalletStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/WalletStateTransformer.kt @@ -15,17 +15,22 @@ internal abstract class WalletStateTransformer( abstract fun transform(walletUM: WalletUM): WalletUM final override fun transform(prevState: WalletScreenState): WalletScreenState { - return prevState.copy( - wallets = prevState.wallets - .map { state -> - if (state.walletCardState.id == userWalletId) transform(state) else state - } - .toImmutableList(), - wallets2 = prevState.wallets2 - .map { walletUM -> - if (walletUM.walletsBalanceUM.id == userWalletId) transform(walletUM) else walletUM - } - .toImmutableList(), - ) + return if (prevState.isRedesignEnabled) { + prevState.copy( + wallets2 = prevState.wallets2 + .map { walletUM -> + if (walletUM.walletsBalanceUM.id == userWalletId) transform(walletUM) else walletUM + } + .toImmutableList(), + ) + } else { + prevState.copy( + wallets = prevState.wallets + .map { state -> + if (state.walletCardState.id == userWalletId) transform(state) else state + } + .toImmutableList(), + ) + } } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt index 8285366fb7..c779fe25bd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.testTag @@ -107,6 +108,7 @@ internal fun WalletTopBar( * * @param config component config */ +@Suppress("MagicNumber") @Deprecated("Remove with main toggle [DesignFeatureToggles.isRedesignEnabled]") @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -122,7 +124,9 @@ internal fun WalletTopBar(config: WalletTopBarConfig) { Icon( painter = painterResource(id = action.iconRes), contentDescription = null, - modifier = Modifier.testTag(MainScreenTestTags.MORE_BUTTON), + modifier = Modifier + .rotate(if (action.iconRes == R.drawable.ic_more_default_24) 90f else 0f) + .testTag(MainScreenTestTags.MORE_BUTTON), ) } } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index ebcd3e5e91..b1e61cdc66 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1461" +tangemBlockchainSdk = "develop-1473" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "develop-598" +tangemCardSdk = "develop-602" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-549" +tangemHotSdk = "develop-550" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^