diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 9dd3c09886..cd16482c4c 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -482,7 +482,7 @@ Networks Mostly used No results - Earn + Staking & Yield mode Hi support team, I\'ve encountered an error with code: %s WalletConnect error You have used a card or ring from another wallet. Tap the card or ring associated with this wallet @@ -706,7 +706,7 @@ Mana level To begin tracking your crypto assets and transactions, add tokens Manage tokens - Scan QR code to send funds or connect to an app + Scan QR code to send funds or connect to an app To access all the networks you need to scan the card Scan your card or ring Enjoy %1$s service fees on swaps via Changelly from February %2$s-%3$s @@ -741,7 +741,7 @@ APY %s My portfolio Market - Earn with Tangem + Staking & Yield mode To generate addresses for selected networks, you must scan your Tangem Wallet card or ring To add tokens pull this up or tap the search bar Swipe up to explore the market @@ -1054,7 +1054,7 @@ The purchase amount should be no more than %s The amount to buy must be at least %s No available providers for this currency - Quickest Processing + Quickest processing Pay with Payment method Available up to %s @@ -1104,6 +1104,12 @@ Settings You have not given access to your camera Camera access denied + Requested token is not added to your wallet. Please add it and try again. + Token not added + Sorry, this QR code could not be recognized. + Unrecognized QR Code + This network isn\'t supported by any of your added tokens. Add a supported token to send crypto. + No supported tokens found No memo required %1$s (%2$s) on %3$s network %1$s on %2$s network @@ -1249,6 +1255,11 @@ Transaction limitation Optional Please align your QR code with the square to scan it. Ensure you scan %s network address. + When you use a fixed rate, the amount you receive is locked in at the time of the swap. This protects you from price changes during the transaction. + Fixed rate + A floating rate means the final amount you receive may change slightly based on market conditions between the time you start and complete the swap. + Floating rate + Rate is fixed Recent Recipient Not a valid address @@ -1292,6 +1303,7 @@ Recipient gets %s Are you sure you want to cancel the conversion? Your previous data will be cleared. Remove Conversion + Something went wrong. Try again. Send with swap Transaction sent Prepare to scan card or ring you want to set up. @@ -1458,8 +1470,8 @@ The network will charge a token approval fee to verify that you are authorizing the use of your token for the swap. Exchange more tokens at better rates directly in your wallet. New Swap Provider Available! - Looking for something else?\nTry searching or explore another crypto! - Search for any token, even if it’s not in your list yet. + Looking for something else?\nTry searching or explore another crypto! + Search for any token, even if it’s not in your list yet. Use search to find what you need Feel confident with round-the-clock support to help with any issues Always Here 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 fc33495e08..0d6ed638f3 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 @@ -17,11 +17,17 @@ internal class Bip321PaymentUriParser( val (matchingCoins, withoutScheme) = schemeAndRest val parsed = QrSentUriParser().parse(withoutScheme) - ?: return PaymentUriParser.ParseResult.RecognizedButNoMatch + ?: return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.Unrecognized(qrCode), + ) val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() val matchingCurrencies = allCurrencies.filter { it.network.id in matchingNetworkIds } - if (matchingCurrencies.isEmpty()) return PaymentUriParser.ParseResult.RecognizedButNoMatch + if (matchingCurrencies.isEmpty()) { + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.UnsupportedNetwork, + ) + } return PaymentUriParser.ParseResult.Success( ClassifiedQrContent.PaymentUri( 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 3853849fcd..5a47f9b717 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 @@ -23,10 +23,17 @@ internal class Eip681PaymentUriParser( val matchingCoins = findMatchingCoins(parsed.chainId, coins) if (matchingCoins.isEmpty()) { - return PaymentUriParser.ParseResult.RecognizedButNoMatch + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.UnsupportedNetwork, + ) } val result = if (parsed.functionName == FUNCTION_TRANSFER) { + if (PARAM_ADDRESS !in parsed.params) { + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.Unrecognized(qrCode), + ) + } resolveErc20Transfer(parsed, matchingCoins, allCurrencies) } else { resolveNativeTransfer(parsed, matchingCoins, allCurrencies) @@ -34,7 +41,9 @@ internal class Eip681PaymentUriParser( return if (result != null) { PaymentUriParser.ParseResult.Success(result) } else { - PaymentUriParser.ParseResult.RecognizedButNoMatch + PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.UnsupportedNetwork, + ) } } 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 8ade342f92..e7a64b8740 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 @@ -11,8 +11,8 @@ internal interface PaymentUriParser { /** URI format not recognized by this parser. */ data object NotRecognized : ParseResult() - /** URI format recognized but no matching currencies found. */ - data object RecognizedButNoMatch : ParseResult() + /** URI format recognized but resulted in an error. */ + data class RecognizedError(val error: ClassifiedQrContent.Error) : ParseResult() /** Successfully parsed with matching currencies. */ data class Success(val content: ClassifiedQrContent.PaymentUri) : ParseResult() 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 6d970e3ec4..ef468d89b0 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 @@ -1,5 +1,6 @@ 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.models.network.Network @@ -25,7 +26,7 @@ internal class QrContentClassifierParser( when (val paymentUriResult = tryParsePaymentUri(qrCode, uniqueCoins, userCurrencies)) { is PaymentUriParser.ParseResult.Success -> return paymentUriResult.content - is PaymentUriParser.ParseResult.RecognizedButNoMatch -> return ClassifiedQrContent.Unknown(qrCode) + is PaymentUriParser.ParseResult.RecognizedError -> return paymentUriResult.error is PaymentUriParser.ParseResult.NotRecognized -> Unit } @@ -35,13 +36,18 @@ internal class QrContentClassifierParser( if (matchingNetworkIds.isNotEmpty()) { val matchingCurrencies = userCurrencies.filter { it.network.id in matchingNetworkIds } + return ClassifiedQrContent.PlainAddress( address = qrCode, matchingCurrencies = matchingCurrencies, ) } - return ClassifiedQrContent.Unknown(qrCode) + if (blockchainDataProvider.isSupportedAddress(qrCode)) { + return ClassifiedQrContent.Error.UnsupportedNetwork + } + + return ClassifiedQrContent.Error.Unrecognized(qrCode) } private fun tryParsePaymentUri( @@ -70,6 +76,7 @@ internal class QrContentClassifierParser( fun getShareSchemes(network: Network): List fun validateAddress(network: Network, address: String): Boolean fun getChainId(network: Network): Long? + fun isSupportedAddress(address: String): Boolean } internal class DefaultBlockchainDataProvider : BlockchainDataProvider { @@ -84,6 +91,14 @@ internal class QrContentClassifierParser( override fun getChainId(network: Network): Long? { return runCatching { network.toBlockchain().getChainId()?.toLong() }.getOrNull() } + + override fun isSupportedAddress(address: String): Boolean { + return Blockchain.entries + .filter { !it.isTestnet() } + .any { blockchain -> + runCatching { blockchain.validateAddress(address) }.getOrDefault(false) + } + } } private companion object { 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 f78e4d22da..7b0662377e 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 @@ -142,7 +142,7 @@ internal class Eip681PaymentUriParserTest { } @Test - fun `ERC-20 transfer with unknown token returns RecognizedButNoMatch`() { + fun `ERC-20 transfer with unknown token returns UnsupportedNetwork error`() { every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L val result = parser.parse( @@ -151,11 +151,13 @@ internal class Eip681PaymentUriParserTest { allCurrencies = listOf(ethereumCoin), ) - assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedButNoMatch::class.java) + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + val error = (result as PaymentUriParser.ParseResult.RecognizedError).error + assertThat(error).isInstanceOf(ClassifiedQrContent.Error.UnsupportedNetwork::class.java) } @Test - fun `ERC-20 transfer without address param returns RecognizedButNoMatch`() { + fun `ERC-20 transfer without address param returns Unrecognized error`() { every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") @@ -166,7 +168,9 @@ internal class Eip681PaymentUriParserTest { allCurrencies = listOf(ethereumCoin, usdcToken), ) - assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedButNoMatch::class.java) + 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 @@ -192,7 +196,7 @@ internal class Eip681PaymentUriParserTest { // region Chain ID matching @Test - fun `chain_id mismatch returns RecognizedButNoMatch`() { + fun `chain_id mismatch returns UnsupportedNetwork error`() { every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L val result = parser.parse( @@ -201,7 +205,9 @@ internal class Eip681PaymentUriParserTest { allCurrencies = listOf(ethereumCoin), ) - assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedButNoMatch::class.java) + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + val error = (result as PaymentUriParser.ParseResult.RecognizedError).error + assertThat(error).isInstanceOf(ClassifiedQrContent.Error.UnsupportedNetwork::class.java) } @Test 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 c8b289207f..86075b2a10 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 @@ -17,6 +17,7 @@ internal class QrContentClassifierTest { every { getShareSchemes(any()) } returns emptyList() every { validateAddress(any(), any()) } returns false every { getChainId(any()) } returns null + every { isSupportedAddress(any()) } returns false } private val paymentUriParser = mockk { every { parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.NotRecognized @@ -63,7 +64,7 @@ internal class QrContentClassifierTest { val result = classifier.parse(url, listOf(bitcoinCoin)) - assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java) } @Test @@ -72,7 +73,7 @@ internal class QrContentClassifierTest { val result = classifier.parse(url, listOf(bitcoinCoin)) - assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java) } // endregion @@ -162,22 +163,22 @@ internal class QrContentClassifierTest { fun `Random string returns Unknown`() { val result = classifier.parse("hello world", listOf(bitcoinCoin, ethereumCoin)) - assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) - assertThat((result as ClassifiedQrContent.Unknown).raw).isEqualTo("hello world") + assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java) + assertThat((result as ClassifiedQrContent.Error.Unrecognized).raw).isEqualTo("hello world") } @Test fun `Empty string returns Unknown`() { val result = classifier.parse("", listOf(bitcoinCoin)) - assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java) } @Test fun `Empty currencies list returns Unknown`() { val result = classifier.parse("0x1234567890abcdef1234567890abcdef12345678", emptyList()) - assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java) } @Test @@ -186,7 +187,7 @@ internal class QrContentClassifierTest { val result = classifier.parse("0x1234", listOf(token)) - assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java) } // endregion 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 dab8a60259..da41ec31fa 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,5 +19,12 @@ sealed class ClassifiedQrContent { val matchingCurrencies: List, ) : ClassifiedQrContent() - data class Unknown(val raw: String) : ClassifiedQrContent() + sealed class Error : ClassifiedQrContent() { + + /** QR code not recognized by any parser */ + data class Unrecognized(val raw: String) : Error() + + /** Network or token recognized but not available in user's wallet */ + data object UnsupportedNetwork : Error() + } } \ No newline at end of file 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 d0443ffdd7..7220a7ef95 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 @@ -41,5 +41,5 @@ sealed class QrSendTarget { data class WalletConnect(val uri: String) : QrSendTarget() - data class Unknown(val raw: String) : QrSendTarget() + data class Error(val error: ClassifiedQrContent.Error) : QrSendTarget() } \ No newline at end of file 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 bfa7169dac..a33e20ec2d 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 @@ -52,7 +52,7 @@ class ResolveQrSendTargetsUseCase( private fun resolve(classified: ClassifiedQrContent, portfolioIndex: PortfolioIndex): QrSendTarget { return when (classified) { is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri) - is ClassifiedQrContent.Unknown -> QrSendTarget.Unknown(classified.raw) + is ClassifiedQrContent.Error -> QrSendTarget.Error(classified) is ClassifiedQrContent.PlainAddress -> resolveAddressTarget( address = classified.address, amount = null, 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 44c6eb55fc..5340b32bc2 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 @@ -11,6 +11,7 @@ import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase @@ -26,6 +27,7 @@ import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.qrscanning.models.QrResultSource import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase +import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.domain.qrscanning.models.QrSendTarget import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.model.WcPairRequest @@ -47,6 +49,7 @@ import com.tangem.feature.wallet.presentation.wallet.domain.WalletImageResolver import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAlertUM import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent.DemonstrateWalletsScrollPreview.Direction @@ -115,6 +118,7 @@ internal class WalletModel @Inject constructor( private val listenToQrScanningUseCase: ListenToQrScanningUseCase, private val wcPairService: WcPairService, private val resolveQrSendTargetsUseCase: ResolveQrSendTargetsUseCase, + private val uiMessageSender: UiMessageSender, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -778,8 +782,17 @@ internal class WalletModel @Inject constructor( is QrSendTarget.Multiple -> { innerWalletRouter.openNetworkSelectionBottomSheet(target) } - is QrSendTarget.Unknown -> { - // TODO: [REDACTED_TASK_KEY] Error handling for unsupported and invalid QR codes + is QrSendTarget.Error -> handleQrError(target.error) + } + } + + private fun handleQrError(error: ClassifiedQrContent.Error) { + when (error) { + is ClassifiedQrContent.Error.Unrecognized -> { + uiMessageSender.send(WalletAlertUM.qrCodeUnrecognized()) + } + is ClassifiedQrContent.Error.UnsupportedNetwork -> { + uiMessageSender.send(WalletAlertUM.qrCodeUnsupportedNetwork()) } } } 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 a0fe35468c..665fcfdafd 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 @@ -80,6 +80,20 @@ internal object WalletAlertUM { ) } + fun qrCodeUnrecognized(): DialogMessage { + return DialogMessage( + title = resourceReference(R.string.qr_scanner_error_unrecognized_title), + message = resourceReference(R.string.qr_scanner_error_unrecognized_message), + ) + } + + fun qrCodeUnsupportedNetwork(): DialogMessage { + return DialogMessage( + title = resourceReference(R.string.qr_scanner_error_token_not_added_title), + message = resourceReference(R.string.qr_scanner_error_token_not_added_message), + ) + } + fun confirmExpressStatusHide(onConfirmClick: () -> Unit): DialogMessage { return DialogMessage( title = resourceReference(R.string.express_status_hide_dialog_title),