From 8a79e65e2e97900347a28dcda647392f95248654 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 10 Mar 2026 15:12:44 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/di/domain/QrScanningDomainModule.kt | 7 + .../qrscanning/di/QrScanningDataModule.kt | 8 +- .../parser/QrContentClassifierParser.kt | 94 ++++++ .../data/qrscanning/parser/QrSentUriParser.kt | 61 ++++ .../DefaultQrScanningEventsRepository.kt | 133 ++------ .../DefaultQrScanningEventsRepositoryTest.kt | 4 +- .../qrscanning/QrContentClassifierTest.kt | 305 ++++++++++++++++++ domain/qr-scanning/models/build.gradle.kts | 5 + .../qrscanning/models/ClassifiedQrContent.kt | 23 ++ .../repository/QrScanningEventsRepository.kt | 3 + .../usecases/ClassifyQrCodeUseCase.kt | 13 + features/wallet/impl/build.gradle.kts | 4 + .../wallet/child/wallet/model/WalletModel.kt | 73 +++++ .../router/DefaultWalletRouter.kt | 18 ++ .../presentation/router/InnerWalletRouter.kt | 3 + .../wallet/qr/ClassifiedQrContent.kt | 23 ++ .../wallet/qr/QrContentClassifier.kt | 120 +++++++ .../wallet/qr/QrContentClassifierTest.kt | 303 +++++++++++++++++ 18 files changed, 1099 insertions(+), 101 deletions(-) create mode 100644 data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt create mode 100644 data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt create mode 100644 data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt create mode 100644 domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt create mode 100644 domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ClassifyQrCodeUseCase.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/ClassifiedQrContent.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifier.kt create mode 100644 features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt 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 ec86de8c46..25a32f91ed 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 @@ -1,6 +1,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository +import com.tangem.domain.qrscanning.usecases.ClassifyQrCodeUseCase import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase @@ -31,4 +32,10 @@ internal object QrScanningDomainModule { fun provideParseQrCodeUseCase(repository: QrScanningEventsRepository): ParseQrCodeUseCase { return ParseQrCodeUseCase(repository) } + + @Provides + @Singleton + fun provideClassifyQrCodeUseCase(repository: QrScanningEventsRepository): ClassifyQrCodeUseCase { + return ClassifyQrCodeUseCase(repository) + } } \ 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 efe697f667..9fd1b269d8 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 @@ -1,5 +1,6 @@ package com.tangem.data.qrscanning.di +import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository import dagger.Module @@ -15,6 +16,11 @@ internal object QrScanningDataModule { @Provides @Singleton fun provideQrScanningEventsRepository(): QrScanningEventsRepository { - return DefaultQrScanningEventsRepository() + return DefaultQrScanningEventsRepository( + qrContentClassifierParser = QrContentClassifierParser( + QrContentClassifierParser.DefaultBlockchainDataProvider + (), + ), + ) } } \ 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 new file mode 100644 index 0000000000..7a881c2a6f --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt @@ -0,0 +1,94 @@ +package com.tangem.data.qrscanning.parser + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import java.net.URLDecoder + +internal class QrContentClassifierParser( + private val blockchainDataProvider: BlockchainDataProvider, + private val paymentUriParser: QrSentUriParser = QrSentUriParser(), +) { + + fun parse(qrCode: String, userCurrencies: List): ClassifiedQrContent { + if (qrCode.startsWith(WC_PREFIX)) { + return ClassifiedQrContent.WalletConnect(qrCode) + } + + if (isDAppWcUrl(qrCode)) { + return ClassifiedQrContent.WalletConnect(qrCode) + } + + val coins = userCurrencies.filterIsInstance() + val uniqueCoins = coins.distinctBy { it.network.id } + + val paymentUri = tryParsePaymentUri(qrCode, uniqueCoins) + if (paymentUri != null) return paymentUri + + val matchingCurrencies = uniqueCoins.filter { coin -> + blockchainDataProvider.validateAddress(coin.network, qrCode) + } + + if (matchingCurrencies.isNotEmpty()) { + return ClassifiedQrContent.PlainAddress( + address = qrCode, + matchingCurrencies = matchingCurrencies, + ) + } + + return ClassifiedQrContent.Unknown(qrCode) + } + + private fun tryParsePaymentUri(qrCode: String, coins: List): ClassifiedQrContent.PaymentUri? { + return coins.firstNotNullOfOrNull { coin -> + val matchedScheme = blockchainDataProvider.getShareSchemes(coin.network) + .sortedByDescending { it.length } + .firstOrNull { qrCode.startsWith(it) } + ?: return@firstNotNullOfOrNull null + + val withoutScheme = qrCode.removePrefix(matchedScheme) + val parsed = paymentUriParser.parse(withoutScheme) ?: return@firstNotNullOfOrNull null + + ClassifiedQrContent.PaymentUri( + currency = coin, + address = parsed.address, + amount = parsed.amount, + memo = parsed.memo, + ) + } + } + + private fun isDAppWcUrl(qrCode: String): Boolean { + if (!qrCode.startsWith(HTTP_PREFIX) && !qrCode.startsWith(HTTPS_PREFIX)) return false + + val uriParam = paymentUriParser.extractParameters(qrCode)[PARAM_URI] ?: return false + val decodedUri = runCatching { URLDecoder.decode( + uriParam, + QrSentUriParser.CHARSET_UTF8, + ) }.getOrDefault(uriParam) + return decodedUri.startsWith(WC_PREFIX) + } + + internal interface BlockchainDataProvider { + fun getShareSchemes(network: Network): List + fun validateAddress(network: Network, address: String): Boolean + } + + 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) + } + } + + private companion object { + const val HTTP_PREFIX = "http://" + const val HTTPS_PREFIX = "https://" + const val PARAM_URI = "uri" + const val WC_PREFIX = "wc:" + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..ea80fb564d --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt @@ -0,0 +1,61 @@ +package com.tangem.data.qrscanning.parser + +import java.math.BigDecimal +import java.net.URLDecoder + +internal class QrSentUriParser { + + data class Result( + val address: String, + val amount: BigDecimal?, + val memo: String?, + val params: Map, + ) + + fun parse(withoutScheme: String): Result? { + val address = withoutScheme.takeWhile { + it != CHAIN_DELIMITER && it != FUNCTION_DELIMITER && it != PARAM_DELIMITER + } + if (address.isBlank()) return null + + 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) + } + + return Result( + address = address, + amount = amount, + memo = memo, + params = params, + ) + } + + fun extractParameters(from: String): Map { + val paramsBlock = from.substringAfter(PARAM_DELIMITER, missingDelimiterValue = "") + if (paramsBlock.isBlank()) return emptyMap() + + return paramsBlock.split(PARAMS_DELIMITER) + .mapNotNull { param -> + val parts = param.split(PARAM_VALUE_DELIMITER, limit = 2) + if (parts.size == 2) parts[0].lowercase() to parts[1] else null + } + .toMap() + } + + companion object { + const val CHAIN_DELIMITER = '@' + const val FUNCTION_DELIMITER = '/' + const val PARAM_DELIMITER = '?' + 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" + const val CHARSET_UTF8 = "UTF-8" + } +} \ 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 c5573994ce..33d6158669 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 @@ -2,7 +2,10 @@ package com.tangem.data.qrscanning.repository import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.core.ui.utils.parseBigDecimalOrNull +import com.tangem.data.qrscanning.parser.QrSentUriParser +import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.domain.qrscanning.models.QrResult import com.tangem.domain.qrscanning.models.RawQrResult import com.tangem.domain.qrscanning.models.SourceType @@ -14,9 +17,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.yield import java.math.BigDecimal -import java.net.URLDecoder -internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository { +internal class DefaultQrScanningEventsRepository( + private val qrContentClassifierParser: QrContentClassifierParser, + private val paymentUriParser: QrSentUriParser = QrSentUriParser(), +) : QrScanningEventsRepository { private data class QrScanningEvent(val qrCode: RawQrResult) @@ -37,120 +42,50 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository { override fun parseQrCode(qrCode: String, cryptoCurrency: CryptoCurrency): QrResult { val withoutSchema = stripSchema(qrCode, cryptoCurrency) + val parsed = paymentUriParser.parse(withoutSchema) + ?: return QrResult(address = withoutSchema) - // A poor man's ERC-681 parser: we want to extract only the destination address, and we don't care - // about other parts of the ERC-681 payload string like `chain_id` and/or `function_name`. - // - // We're extracting the destination address by parsing the given string until we meet - // any of the possible string delimiters (@ ? /). - val address = withoutSchema.takeWhile { char -> - char != CHAIN_DELIMITER && char != FUNCTION_DELIMITER && char != PARAM_DELIMITER + val result = QrResult(address = parsed.address) + result.amount = parsed.amount + result.memo = parsed.memo + + // 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 -> + val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult() + if (tokenCurrency.contractAddress.equals(parsed.address, ignoreCase = true)) { + result.address = addressValue + } else { + return QrResult() + } } - val result = QrResult(address = address) - - extractParameters(withoutSchema) - .forEach { entry -> - when (entry.key) { - Parameter.Amount -> { - // According to BIP-0021, the value is specified in decimals. No conversion needed - result.amount = entry.value.parseBigDecimalOrNull() - } - Parameter.Message, - Parameter.Memo, - -> { - result.memo = URLDecoder.decode(entry.value, "UTF-8") - } - Parameter.Address -> { - // If 'address' parameter is exists, then currency must be TOKEN. - val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult() - - // Overrides destination address for token transfers (ERC-681) - // `address` parameter is used only if the contract address, encoded in the QR, - // matches the contract address of the token. - // Otherwise, the scanned string is likely malformed, and we stop the entire parsing routin - if (tokenCurrency.contractAddress.equals(address, ignoreCase = true)) { - result.address = entry.value - } else { - return QrResult() - } - } - Parameter.Value, - Parameter.Uint256, - -> { - // Extra convert parses scientific notation to decimal - // This is necessary to be able comparing BigDecimal values - result.amount = entry.value.parseBigDecimalOrNull() - ?.toPlainString()?.toBigDecimalOrNull() - ?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals)) - } - } - } + // ERC-681: value/uint256 is in the smallest unit, needs conversion + val valueStr = parsed.params[QrSentUriParser.PARAM_VALUE] + ?: parsed.params[QrSentUriParser.PARAM_UINT256] + if (valueStr != null) { + result.amount = valueStr.parseBigDecimalOrNull() + ?.toPlainString()?.toBigDecimalOrNull() + ?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals)) + } return result } + override fun classify(qrCode: String, userCurrencies: List): ClassifiedQrContent { + return qrContentClassifierParser.parse(qrCode, userCurrencies) + } + private fun stripSchema(raw: String, currency: CryptoCurrency): String { val qrSchemas = currency.network.toBlockchain().getShareScheme() - // The most specific (i.e. the most lengthy) prefixes always come first qrSchemas .sortedByDescending { it.length } .forEach { schema -> val stripped = raw.split(schema) - if (stripped.size > 1) return stripped.last() } return raw } - - private fun extractParameters(from: String): Map { - val parametersBlock = from.substringAfter(PARAM_DELIMITER) - if (parametersBlock.isBlank()) return emptyMap() - - val paramList = parametersBlock.split(PARAMS_DELIMITER) - .mapNotNull { param -> - val parameterWithValue = param.split(PARAM_VALUE_DELIMITER) - if (parameterWithValue.size == 2) { - val name = Parameter.getParam(parameterWithValue.first()) - val value = parameterWithValue.last() - - if (name != null) { - name to value - } else { - null - } - } else { - null - } - }.associate { it } - - return paramList - } - - private enum class Parameter { - Amount, - Message, - Memo, - Address, - Value, - Uint256, - ; - - companion object { - fun getParam(name: String): Parameter? { - return Parameter.entries.firstOrNull { it.name.equals(name, ignoreCase = true) } - } - } - } - - private companion object { - // See https://eips.ethereum.org/EIPS/eip-681 for details. - const val CHAIN_DELIMITER = '@' // ERC-681 [ "@" chain_id ] - const val FUNCTION_DELIMITER = '/' // ERC-681 [ "/" function_name ] - const val PARAM_DELIMITER = '?' // BIP-021, ERC-681 - const val PARAMS_DELIMITER = '&' - const val PARAM_VALUE_DELIMITER = '=' - } } \ No newline at end of file diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt index 5e58da8cc7..3aecfcfbfa 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt @@ -2,6 +2,7 @@ package com.tangem.data.qrscanning import com.google.common.truth.Truth import com.tangem.blockchain.common.Blockchain +import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -13,7 +14,8 @@ import java.math.BigDecimal internal class DefaultQrScanningEventsRepositoryTest { - private val repository = DefaultQrScanningEventsRepository() + private val qrContentClassifier: QrContentClassifierParser = mockk() + private val repository = DefaultQrScanningEventsRepository(qrContentClassifier) private val cryptoCurrencyId = mockk() private val network = mockk() 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 new file mode 100644 index 0000000000..b4dc4c9976 --- /dev/null +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt @@ -0,0 +1,305 @@ +package com.tangem.data.qrscanning + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.qrscanning.parser.QrContentClassifierParser +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 QrContentClassifierTest { + + private val blockchainDataProvider = mockk { + every { getShareSchemes(any()) } returns emptyList() + every { validateAddress(any(), any()) } returns false + } + private val classifier = QrContentClassifierParser(blockchainDataProvider) + + // region WalletConnect + + @Test + fun `WalletConnect URI is classified correctly`() { + val uri = "wc:a4f86d5-72ac-46ad-a1aa-9e@1-f5c0c@2" + val result = classifier.parse(uri, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java) + assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(uri) + } + + @Test + fun `WalletConnect URI takes priority over address matching`() { + val uri = "wc:something" + every { blockchainDataProvider.validateAddress(any(), uri) } returns true + + val result = classifier.parse(uri, listOf(bitcoinCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java) + } + + @Test + fun `dApp URL with wc uri query param is classified as WalletConnect`() { + val dAppUrl = "https://uniswap.org/app/wc?uri=wc:6ea45@2?relay-protocol=irn&symKey=f2de&expiryTimestamp=123" + + val result = classifier.parse(dAppUrl, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java) + assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(dAppUrl) + } + + @Test + fun `HTTP URL without wc uri param is not classified as WalletConnect`() { + val url = "https://example.com/page?foo=bar" + + val result = classifier.parse(url, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `HTTP URL with uri param not starting with wc is not WalletConnect`() { + val url = "https://example.com/page?uri=https://other.com" + + val result = classifier.parse(url, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + // endregion + + // region PaymentUri + + @Test + fun `Bitcoin BIP-021 URI with amount is parsed`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5" + val result = classifier.parse(qr, listOf(bitcoinCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.currency).isEqualTo(bitcoinCoin) + assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(paymentUri.amount).isEqualTo(BigDecimal("0.5")) + assertThat(paymentUri.memo).isNull() + } + + @Test + fun `Bitcoin URI without params returns address only`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + val result = classifier.parse(qr, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(paymentUri.amount).isNull() + assertThat(paymentUri.memo).isNull() + } + + @Test + fun `Bitcoin URI with message param is parsed as memo`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.0&message=test%20memo" + val result = classifier.parse(qr, listOf(bitcoinCoin)) + + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.0")) + assertThat(paymentUri.memo).isEqualTo("test memo") + } + + @Test + fun `URI with memo parameter is parsed`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?memo=hello" + val result = classifier.parse(qr, listOf(bitcoinCoin)) + + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.memo).isEqualTo("hello") + } + + @Test + fun `Ethereum ERC-681 URI with chain_id and function is parsed`() { + every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:") + + val qr = "ethereum:0x1234567890abcdef1234567890abcdef12345678@1/transfer?amount=1.5" + val result = classifier.parse(qr, listOf(ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.address).isEqualTo("0x1234567890abcdef1234567890abcdef12345678") + assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.5")) + } + + @Test + fun `URI scheme not matching user currencies falls through`() { + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + val result = classifier.parse(qr, listOf(ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `Longest matching scheme is preferred`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns + listOf("bitcoin:", "bitcoin://") + + val qr = "bitcoin://1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + val result = classifier.parse(qr, listOf(bitcoinCoin)) + + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + } + + // endregion + + // region PlainAddress + + @Test + fun `Plain address matches single currency`() { + val address = "0x1234567890abcdef1234567890abcdef12345678" + every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true + + val result = classifier.parse(address, listOf(ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) + val plain = result as ClassifiedQrContent.PlainAddress + assertThat(plain.address).isEqualTo(address) + assertThat(plain.matchingCurrencies).containsExactly(ethereumCoin) + } + + @Test + fun `Plain address matches multiple currencies`() { + val address = "0x1234567890abcdef1234567890abcdef12345678" + every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true + every { blockchainDataProvider.validateAddress(bscCoin.network, address) } returns true + + val result = classifier.parse(address, listOf(ethereumCoin, bscCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) + val plain = result as ClassifiedQrContent.PlainAddress + assertThat(plain.matchingCurrencies).hasSize(2) + } + + // endregion + + // region Unknown + + @Test + 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") + } + + @Test + fun `Empty string returns Unknown`() { + val result = classifier.parse("", listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `Empty currencies list returns Unknown`() { + val result = classifier.parse("0x1234567890abcdef1234567890abcdef12345678", emptyList()) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + // endregion + + // region Edge cases + + @Test + fun `Tokens are filtered out, only Coins are used`() { + val token = CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = buildNetwork("ethereum"), + name = "USDT", + symbol = "USDT", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ) + + val result = classifier.parse("0x1234", listOf(token)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `Duplicate coins with same network are deduplicated`() { + val address = "0x1234567890abcdef1234567890abcdef12345678" + every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true + + val result = classifier.parse(address, listOf(ethereumCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) + val plain = result as ClassifiedQrContent.PlainAddress + assertThat(plain.matchingCurrencies).hasSize(1) + } + + @Test + fun `Payment URI takes priority over plain address match`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + every { blockchainDataProvider.validateAddress(bitcoinCoin.network, any()) } returns true + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1" + val result = classifier.parse(qr, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + } + + // endregion + + // region Helpers + + private val bitcoinCoin = buildCoin("bitcoin") + private val ethereumCoin = buildCoin("ethereum") + private val bscCoin = buildCoin("bsc") + + private fun buildCoin(rawNetworkId: String): 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(), + decimals = 8, + iconUrl = null, + isCustom = false, + ) + } + + private fun buildNetwork(rawNetworkId: String): Network { + return Network( + id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), + backendId = rawNetworkId, + name = rawNetworkId, + currencySymbol = rawNetworkId.take(3).uppercase(), + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + // endregion +} \ No newline at end of file diff --git a/domain/qr-scanning/models/build.gradle.kts b/domain/qr-scanning/models/build.gradle.kts index 7ff7fb7522..8a6e467961 100644 --- a/domain/qr-scanning/models/build.gradle.kts +++ b/domain/qr-scanning/models/build.gradle.kts @@ -1,4 +1,9 @@ plugins { alias(deps.plugins.kotlin.jvm) id("configuration") +} +dependencies { + + /** Domain */ + implementation(projects.domain.models) } \ No newline at end of file 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 new file mode 100644 index 0000000000..4c482b56a5 --- /dev/null +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.qrscanning.models + +import com.tangem.domain.models.currency.CryptoCurrency +import java.math.BigDecimal + +sealed class ClassifiedQrContent { + + data class WalletConnect(val uri: String) : ClassifiedQrContent() + + data class PaymentUri( + val currency: CryptoCurrency, + val address: String, + val amount: BigDecimal?, + val memo: String?, + ) : ClassifiedQrContent() + + data class PlainAddress( + val address: String, + val matchingCurrencies: List, + ) : ClassifiedQrContent() + + data class Unknown(val raw: String) : ClassifiedQrContent() +} \ No newline at end of file diff --git a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/repository/QrScanningEventsRepository.kt b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/repository/QrScanningEventsRepository.kt index 323e57d915..c9a2c43dfe 100644 --- a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/repository/QrScanningEventsRepository.kt +++ b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/repository/QrScanningEventsRepository.kt @@ -1,6 +1,7 @@ package com.tangem.domain.qrscanning.repository import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.domain.qrscanning.models.QrResult import com.tangem.domain.qrscanning.models.RawQrResult import com.tangem.domain.qrscanning.models.SourceType @@ -13,4 +14,6 @@ interface QrScanningEventsRepository { fun subscribeToScanningResults(type: SourceType): Flow fun parseQrCode(qrCode: String, cryptoCurrency: CryptoCurrency): QrResult + + fun classify(qrCode: String, userCurrencies: List): ClassifiedQrContent } \ No newline at end of file diff --git a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ClassifyQrCodeUseCase.kt b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ClassifyQrCodeUseCase.kt new file mode 100644 index 0000000000..16180975e0 --- /dev/null +++ b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ClassifyQrCodeUseCase.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.qrscanning.usecases + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository + +class ClassifyQrCodeUseCase( + private val repository: QrScanningEventsRepository, +) { + operator fun invoke(qrCode: String, userCurrencies: List): ClassifiedQrContent { + return repository.classify(qrCode, userCurrencies) + } +} \ No newline at end of file diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index b7e61208c1..4c28061ca1 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -97,6 +97,10 @@ dependencies { implementation(projects.domain.markets.models) implementation(projects.domain.models) implementation(projects.domain.networks) + implementation(projects.domain.qrScanning) + implementation(projects.domain.qrScanning.models) + implementation(projects.domain.walletConnect) + implementation(projects.domain.walletConnect.models) implementation(projects.domain.nft) implementation(projects.domain.nft.models) implementation(projects.domain.hotWallet) 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 94987a166e..fcc3750444 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 @@ -22,8 +22,16 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.* import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase 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.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import com.tangem.domain.walletconnect.WcPairService +import com.tangem.domain.walletconnect.model.WcPairRequest import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase +import com.tangem.domain.qrscanning.usecases.ClassifyQrCodeUseCase import com.tangem.domain.settings.* import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase import com.tangem.domain.wallets.usecase.* @@ -32,6 +40,7 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.router.InnerWalletRouter import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent +import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.feature.wallet.presentation.wallet.analytics.utils.SelectedWalletAnalyticsSender import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory import com.tangem.feature.wallet.presentation.wallet.domain.WalletContentFetcher @@ -104,6 +113,10 @@ internal class WalletModel @Inject constructor( private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getWalletIconUseCase: GetWalletIconUseCase, private val walletFeatureToggles: WalletFeatureToggles, + private val listenToQrScanningUseCase: ListenToQrScanningUseCase, + private val wcPairService: WcPairService, + private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, + private val classifyQrCodeUseCase: ClassifyQrCodeUseCase, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -134,6 +147,7 @@ internal class WalletModel @Inject constructor( subscribeToScreenBackgroundState() subscribeOnPushNotificationsPermission() subscribeTangemPayOnWalletState() + subscribeToMainScreenQrScanning() enableNotificationsIfNeeded() clickIntents.initialize(innerWalletRouter, modelScope) @@ -730,6 +744,65 @@ internal class WalletModel @Inject constructor( } } + private fun subscribeToMainScreenQrScanning() { + listenToQrScanningUseCase.listen(SourceType.MAIN_SCREEN) + .getOrElse { emptyFlow() } + .onEach { rawResult -> handleQrResult(rawResult.qrCode, rawResult.resultSource) } + .launchIn(modelScope) + } + + private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) { + val userWalletId = stateHolder.getSelectedWalletId() + + val currencies = multiWalletCryptoCurrenciesSupplier + .getSyncOrNull(MultiWalletCryptoCurrenciesProducer.Params(userWalletId)) + ?.toList() + .orEmpty() + val classified = classifyQrCodeUseCase(qrCode, currencies) + + when (classified) { + is ClassifiedQrContent.WalletConnect -> { + val source = when (resultSource) { + QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD + QrResultSource.CAMERA, + QrResultSource.GALLERY, + -> WcPairRequest.Source.QR + } + wcPairService.pair( + WcPairRequest( + userWalletId = userWalletId, + uri = classified.uri, + source = source, + ), + ) + } + is ClassifiedQrContent.PaymentUri -> { + innerWalletRouter.openSend( + userWalletId = userWalletId, + currency = classified.currency, + address = classified.address, + amount = classified.amount?.toPlainString(), + tag = classified.memo, + ) + } + is ClassifiedQrContent.PlainAddress -> { + if (classified.matchingCurrencies.size == 1) { + innerWalletRouter.openSend( + userWalletId = userWalletId, + currency = classified.matchingCurrencies.first(), + address = classified.address, + amount = null, + tag = null, + ) + } + // TODO: [REDACTED_TASK_KEY] Network selection bottom sheet for multiple network matches + } + is ClassifiedQrContent.Unknown -> { + // TODO: [REDACTED_TASK_KEY] Error handling for unsupported and invalid QR codes + } + } + } + private fun enableNotificationsIfNeeded() { modelScope.launch { val isUserAllowToEnableNotifications = notificationsRepository.isUserAllowToSubscribeOnPushNotifications() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index 57ad1e77a7..f123c9c3c1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -179,6 +179,24 @@ internal class DefaultWalletRouter @Inject constructor( router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.MainScreen)) } + override fun openSend( + userWalletId: UserWalletId, + currency: CryptoCurrency, + address: String, + amount: String?, + tag: String?, + ) { + router.push( + AppRoute.Send( + userWalletId = userWalletId, + currency = currency, + destinationAddress = address, + amount = amount, + tag = tag, + ), + ) + } + inner class OrganizeCallbacks : OrganizeTokensComponent.Callback { override fun onDismiss() { dialogNavigation.dismiss() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index b05530a666..d87153341b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -92,4 +92,7 @@ internal interface InnerWalletRouter { /** Open QR scanner screen */ fun openQrScanner() + + /** Open send screen with prefilled destination */ + fun openSend(userWalletId: UserWalletId, currency: CryptoCurrency, address: String, amount: String?, tag: String?) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/ClassifiedQrContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/ClassifiedQrContent.kt new file mode 100644 index 0000000000..706a82c5e8 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/ClassifiedQrContent.kt @@ -0,0 +1,23 @@ +package com.tangem.feature.wallet.presentation.wallet.qr + +import com.tangem.domain.models.currency.CryptoCurrency +import java.math.BigDecimal + +internal sealed class ClassifiedQrContent { + + data class WalletConnect(val uri: String) : ClassifiedQrContent() + + data class PaymentUri( + val currency: CryptoCurrency, + val address: String, + val amount: BigDecimal?, + val memo: String?, + ) : ClassifiedQrContent() + + data class PlainAddress( + val address: String, + val matchingCurrencies: List, + ) : ClassifiedQrContent() + + data class Unknown(val raw: String) : ClassifiedQrContent() +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifier.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifier.kt new file mode 100644 index 0000000000..dfabae82b1 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifier.kt @@ -0,0 +1,120 @@ +package com.tangem.feature.wallet.presentation.wallet.qr + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import java.net.URLDecoder + +internal class QrContentClassifier( + private val blockchainDataProvider: BlockchainDataProvider, +) { + + fun classify(qrCode: String, userCurrencies: List): ClassifiedQrContent { + if (qrCode.startsWith(WC_PREFIX)) { + return ClassifiedQrContent.WalletConnect(qrCode) + } + + if (isDAppWcUrl(qrCode)) { + return ClassifiedQrContent.WalletConnect(qrCode) + } + + val coins = userCurrencies.filterIsInstance() + val uniqueCoins = coins.distinctBy { it.network.id } + + val paymentUri = tryParsePaymentUri(qrCode, uniqueCoins) + if (paymentUri != null) return paymentUri + + val matchingCurrencies = uniqueCoins.filter { coin -> + blockchainDataProvider.validateAddress(coin.network, qrCode) + } + + if (matchingCurrencies.isNotEmpty()) { + return ClassifiedQrContent.PlainAddress( + address = qrCode, + matchingCurrencies = matchingCurrencies, + ) + } + + return ClassifiedQrContent.Unknown(qrCode) + } + + private fun tryParsePaymentUri(qrCode: String, coins: List): ClassifiedQrContent.PaymentUri? { + return coins.firstNotNullOfOrNull { coin -> + val matchedScheme = blockchainDataProvider.getShareSchemes(coin.network) + .sortedByDescending { it.length } + .firstOrNull { qrCode.startsWith(it) } + ?: return@firstNotNullOfOrNull null + + val withoutScheme = qrCode.removePrefix(matchedScheme) + val address = withoutScheme.takeWhile { + it != CHAIN_DELIMITER && it != FUNCTION_DELIMITER && it != PARAM_DELIMITER + } + val params = extractParameters(withoutScheme) + + if (address.isBlank()) return@firstNotNullOfOrNull null + + val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull() + val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let { + runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it) + } + + ClassifiedQrContent.PaymentUri( + currency = coin, + address = address, + amount = amount, + memo = memo, + ) + } + } + + private fun isDAppWcUrl(qrCode: String): Boolean { + if (!qrCode.startsWith(HTTP_PREFIX) && !qrCode.startsWith(HTTPS_PREFIX)) return false + + val uriParam = extractParameters(qrCode)[PARAM_URI] ?: return false + val decodedUri = runCatching { URLDecoder.decode(uriParam, CHARSET_UTF8) }.getOrDefault(uriParam) + return decodedUri.startsWith(WC_PREFIX) + } + + private fun extractParameters(from: String): Map { + val paramsBlock = from.substringAfter(PARAM_DELIMITER, missingDelimiterValue = "") + if (paramsBlock.isBlank()) return emptyMap() + + return paramsBlock.split(PARAMS_DELIMITER) + .mapNotNull { param -> + val parts = param.split(PARAM_VALUE_DELIMITER, limit = 2) + if (parts.size == 2) parts[0].lowercase() to parts[1] else null + } + .toMap() + } + + internal interface BlockchainDataProvider { + fun getShareSchemes(network: Network): List + fun validateAddress(network: Network, address: String): Boolean + } + + 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) + } + } + + private companion object { + const val HTTP_PREFIX = "http://" + const val HTTPS_PREFIX = "https://" + const val PARAM_URI = "uri" + const val WC_PREFIX = "wc:" + const val CHAIN_DELIMITER = '@' + const val FUNCTION_DELIMITER = '/' + const val PARAM_DELIMITER = '?' + 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 CHARSET_UTF8 = "UTF-8" + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt new file mode 100644 index 0000000000..5b6afef517 --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt @@ -0,0 +1,303 @@ +package com.tangem.feature.wallet.presentation.wallet.qr + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import java.math.BigDecimal + +internal class QrContentClassifierTest { + + private val blockchainDataProvider = mockk { + every { getShareSchemes(any()) } returns emptyList() + every { validateAddress(any(), any()) } returns false + } + private val classifier = QrContentClassifier(blockchainDataProvider) + + // region WalletConnect + + @Test + fun `WalletConnect URI is classified correctly`() { + val uri = "wc:a4f86d5-72ac-46ad-a1aa-9e@1-f5c0c@2" + val result = classifier.classify(uri, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java) + assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(uri) + } + + @Test + fun `WalletConnect URI takes priority over address matching`() { + val uri = "wc:something" + every { blockchainDataProvider.validateAddress(any(), uri) } returns true + + val result = classifier.classify(uri, listOf(bitcoinCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java) + } + + @Test + fun `dApp URL with wc uri query param is classified as WalletConnect`() { + val dAppUrl = "https://uniswap.org/app/wc?uri=wc:6ea45@2?relay-protocol=irn&symKey=f2de&expiryTimestamp=123" + + val result = classifier.classify(dAppUrl, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java) + assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(dAppUrl) + } + + @Test + fun `HTTP URL without wc uri param is not classified as WalletConnect`() { + val url = "https://example.com/page?foo=bar" + + val result = classifier.classify(url, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `HTTP URL with uri param not starting with wc is not WalletConnect`() { + val url = "https://example.com/page?uri=https://other.com" + + val result = classifier.classify(url, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + // endregion + + // region PaymentUri + + @Test + fun `Bitcoin BIP-021 URI with amount is parsed`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5" + val result = classifier.classify(qr, listOf(bitcoinCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.currency).isEqualTo(bitcoinCoin) + assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(paymentUri.amount).isEqualTo(BigDecimal("0.5")) + assertThat(paymentUri.memo).isNull() + } + + @Test + fun `Bitcoin URI without params returns address only`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + val result = classifier.classify(qr, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(paymentUri.amount).isNull() + assertThat(paymentUri.memo).isNull() + } + + @Test + fun `Bitcoin URI with message param is parsed as memo`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.0&message=test%20memo" + val result = classifier.classify(qr, listOf(bitcoinCoin)) + + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.0")) + assertThat(paymentUri.memo).isEqualTo("test memo") + } + + @Test + fun `URI with memo parameter is parsed`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?memo=hello" + val result = classifier.classify(qr, listOf(bitcoinCoin)) + + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.memo).isEqualTo("hello") + } + + @Test + fun `Ethereum ERC-681 URI with chain_id and function is parsed`() { + every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:") + + val qr = "ethereum:0x1234567890abcdef1234567890abcdef12345678@1/transfer?amount=1.5" + val result = classifier.classify(qr, listOf(ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.address).isEqualTo("0x1234567890abcdef1234567890abcdef12345678") + assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.5")) + } + + @Test + fun `URI scheme not matching user currencies falls through`() { + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + val result = classifier.classify(qr, listOf(ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `Longest matching scheme is preferred`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns + listOf("bitcoin:", "bitcoin://") + + val qr = "bitcoin://1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + val result = classifier.classify(qr, listOf(bitcoinCoin)) + + val paymentUri = result as ClassifiedQrContent.PaymentUri + assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + } + + // endregion + + // region PlainAddress + + @Test + fun `Plain address matches single currency`() { + val address = "0x1234567890abcdef1234567890abcdef12345678" + every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true + + val result = classifier.classify(address, listOf(ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) + val plain = result as ClassifiedQrContent.PlainAddress + assertThat(plain.address).isEqualTo(address) + assertThat(plain.matchingCurrencies).containsExactly(ethereumCoin) + } + + @Test + fun `Plain address matches multiple currencies`() { + val address = "0x1234567890abcdef1234567890abcdef12345678" + every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true + every { blockchainDataProvider.validateAddress(bscCoin.network, address) } returns true + + val result = classifier.classify(address, listOf(ethereumCoin, bscCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) + val plain = result as ClassifiedQrContent.PlainAddress + assertThat(plain.matchingCurrencies).hasSize(2) + } + + // endregion + + // region Unknown + + @Test + fun `Random string returns Unknown`() { + val result = classifier.classify("hello world", listOf(bitcoinCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + assertThat((result as ClassifiedQrContent.Unknown).raw).isEqualTo("hello world") + } + + @Test + fun `Empty string returns Unknown`() { + val result = classifier.classify("", listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `Empty currencies list returns Unknown`() { + val result = classifier.classify("0x1234567890abcdef1234567890abcdef12345678", emptyList()) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + // endregion + + // region Edge cases + + @Test + fun `Tokens are filtered out, only Coins are used`() { + val token = CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = buildNetwork("ethereum"), + name = "USDT", + symbol = "USDT", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + ) + + val result = classifier.classify("0x1234", listOf(token)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java) + } + + @Test + fun `Duplicate coins with same network are deduplicated`() { + val address = "0x1234567890abcdef1234567890abcdef12345678" + every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true + + val result = classifier.classify(address, listOf(ethereumCoin, ethereumCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) + val plain = result as ClassifiedQrContent.PlainAddress + assertThat(plain.matchingCurrencies).hasSize(1) + } + + @Test + fun `Payment URI takes priority over plain address match`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + every { blockchainDataProvider.validateAddress(bitcoinCoin.network, any()) } returns true + + val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1" + val result = classifier.classify(qr, listOf(bitcoinCoin)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java) + } + + // endregion + + // region Helpers + + private val bitcoinCoin = buildCoin("bitcoin") + private val ethereumCoin = buildCoin("ethereum") + private val bscCoin = buildCoin("bsc") + + private fun buildCoin(rawNetworkId: String): 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(), + decimals = 8, + iconUrl = null, + isCustom = false, + ) + } + + private fun buildNetwork(rawNetworkId: String): Network { + return Network( + id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), + backendId = rawNetworkId, + name = rawNetworkId, + currencySymbol = rawNetworkId.take(3).uppercase(), + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = false, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + // endregion +} \ No newline at end of file