Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-17 16:13:15 +03:00
parent 0ce75f1445
commit e0c101f15f
12 changed files with 115 additions and 32 deletions

View file

@ -482,7 +482,7 @@
<string name="earn_filter_networks">Networks</string>
<string name="earn_mostly_used">Mostly used</string>
<string name="earn_no_results">No results</string>
<string name="earn_title">Earn</string>
<string name="earn_title">Staking &amp; Yield mode</string>
<string name="email_preface_wc_error">Hi support team, I\'ve encountered an error with code: %s</string>
<string name="email_subject_wc_error">WalletConnect error</string>
<string name="error_wrong_wallet_tapped">You have used a card or ring from another wallet. Tap the card or ring associated with this wallet</string>
@ -706,7 +706,7 @@
<string name="koinos_mana_level_title">Mana level</string>
<string name="main_empty_tokens_list_message">To begin tracking your crypto assets and transactions, add tokens</string>
<string name="main_manage_tokens">Manage tokens</string>
<string name="main_qr_scan_hint">Scan QR code to send funds or connect to an app</string>
<string name="main_qr_scan_hint">Scan QR code to send funds or connect to an app</string>
<string name="main_scan_card_warning_view_subtitle">To access all the networks you need to scan the card</string>
<string name="main_scan_card_warning_view_title">Scan your card or ring</string>
<string name="main_swap_changelly_promotion_message">Enjoy %1$s service fees on swaps via Changelly from February %2$s-%3$s</string>
@ -741,7 +741,7 @@
<string name="markets_apy_placeholder">APY %s</string>
<string name="markets_common_my_portfolio">My portfolio</string>
<string name="markets_common_title">Market</string>
<string name="markets_earn_common_title">Earn with Tangem</string>
<string name="markets_earn_common_title">Staking &amp; Yield mode</string>
<string name="markets_generate_addresses_notification">To generate addresses for selected networks, you must scan your Tangem Wallet card or ring</string>
<string name="markets_hint">To add tokens pull this up or tap the search bar</string>
<string name="markets_hint_part_one">Swipe up to explore the market</string>
@ -1054,7 +1054,7 @@
<string name="onramp_max_amount_restriction">The purchase amount should be no more than %s</string>
<string name="onramp_min_amount_restriction">The amount to buy must be at least %s</string>
<string name="onramp_no_available_providers">No available providers for this currency</string>
<string name="onramp_offer_type_fastet">Quickest Processing</string>
<string name="onramp_offer_type_fastet">Quickest processing</string>
<string name="onramp_pay_with">Pay with</string>
<string name="onramp_payment_method_subtitle">Payment method</string>
<string name="onramp_provider_max_amount">Available up to %s</string>
@ -1104,6 +1104,12 @@
<string name="qr_scanner_camera_denied_settings_button">Settings</string>
<string name="qr_scanner_camera_denied_text">You have not given access to your camera</string>
<string name="qr_scanner_camera_denied_title">Camera access denied</string>
<string name="qr_scanner_error_token_not_added_message">Requested token is not added to your wallet. Please add it and try again.</string>
<string name="qr_scanner_error_token_not_added_title">Token not added</string>
<string name="qr_scanner_error_unrecognized_message">Sorry, this QR code could not be recognized.</string>
<string name="qr_scanner_error_unrecognized_title">Unrecognized QR Code</string>
<string name="qr_scanner_error_unsupported_network_message">This network isn\'t supported by any of your added tokens. Add a supported token to send crypto.</string>
<string name="qr_scanner_error_unsupported_network_title">No supported tokens found</string>
<string name="receive_bottom_sheet_no_memo_required_message">No memo required</string>
<string name="receive_bottom_sheet_warning_message">%1$s (%2$s) on %3$s network</string>
<string name="receive_bottom_sheet_warning_message_compact">%1$s on %2$s network</string>
@ -1249,6 +1255,11 @@
<string name="send_notification_transaction_limit_title">Transaction limitation</string>
<string name="send_optional_field">Optional</string>
<string name="send_qrcode_scan_info">Please align your QR code with the square to scan it. Ensure you scan %s network address.</string>
<string name="send_rate_fixed_info_description">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.</string>
<string name="send_rate_fixed_info_title">Fixed rate</string>
<string name="send_rate_floating_info_description">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.</string>
<string name="send_rate_floating_info_title">Floating rate</string>
<string name="send_rate_is_fixed">Rate is fixed</string>
<string name="send_recent_transactions">Recent</string>
<string name="send_recipient">Recipient</string>
<string name="send_recipient_address_error">Not a valid address</string>
@ -1292,6 +1303,7 @@
<string name="send_with_swap_recipient_get_amount">Recipient gets %s</string>
<string name="send_with_swap_remove_convert_alert_message">Are you sure you want to cancel the conversion? Your previous data will be cleared.</string>
<string name="send_with_swap_remove_convert_alert_title">Remove Conversion</string>
<string name="send_with_swap_something_went_wrong">Something went wrong. Try again.</string>
<string name="send_with_swap_title">Send with swap</string>
<string name="sent_transaction_sent_title">Transaction sent</string>
<string name="settings_card_settings_footer">Prepare to scan card or ring you want to set up.</string>
@ -1458,8 +1470,8 @@
<string name="swap_give_permission_fee_footer">The network will charge a token approval fee to verify that you are authorizing the use of your token for the swap.</string>
<string name="swap_promo_text">Exchange more tokens at better rates directly in your wallet.</string>
<string name="swap_promo_title">New Swap Provider Available!</string>
<string name="swap_search_suggestion_hint">Looking for something else?\nTry searching or explore another crypto!</string>
<string name="swap_search_tooltip_description">Search for any token, even if its not in your list yet.</string>
<string name="swap_search_suggestion_hint">Looking for something else?\nTry searching or explore another crypto!</string>
<string name="swap_search_tooltip_description">Search for any token, even if its not in your list yet.</string>
<string name="swap_search_tooltip_title">Use search to find what you need</string>
<string name="swap_story_fifth_subtitle">Feel confident with round-the-clock support to help with any issues</string>
<string name="swap_story_fifth_title">Always Here</string>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,5 +19,12 @@ sealed class ClassifiedQrContent {
val matchingCurrencies: List<CryptoCurrency>,
) : 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()
}
}

View file

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

View file

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

View file

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

View file

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