From 473aeee67516c7a6d4effa69e4e6d607920ecd53 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 13 Mar 2026 12:51:12 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/di/domain/QrScanningDomainModule.kt | 13 +- .../qrscanning/di/QrScanningDataModule.kt | 10 +- .../parser/Bip321PaymentUriParser.kt | 54 ++++ .../parser/Eip681PaymentUriParser.kt | 149 +++++++++ .../qrscanning/parser/PaymentUriParser.kt | 20 ++ .../parser/QrContentClassifierParser.kt | 52 +-- .../qrscanning/Bip321PaymentUriParserTest.kt | 269 +++++++++++++++ .../qrscanning/Eip681PaymentUriParserTest.kt | 305 ++++++++++++++++++ .../qrscanning/QrContentClassifierTest.kt | 191 ++++------- domain/qr-scanning/build.gradle.kts | 1 + .../qrscanning/models/ClassifiedQrContent.kt | 2 +- .../domain/qrscanning/models/QrSendTarget.kt | 44 +++ .../usecases/ClassifyQrCodeUseCase.kt | 13 - .../usecases/ResolveQrSendTargetsUseCase.kt | 153 +++++++++ domain/wallets/build.gradle.kts | 5 + .../features/send/v2/send/model/SendModel.kt | 66 ++-- .../destination/model/SendDestinationModel.kt | 2 +- .../wallet/child/wallet/model/WalletModel.kt | 53 +-- 18 files changed, 1176 insertions(+), 226 deletions(-) create mode 100644 data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt create mode 100644 data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt create mode 100644 data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriParser.kt create mode 100644 data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt create mode 100644 data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt create mode 100644 domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt delete mode 100644 domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ClassifyQrCodeUseCase.kt create mode 100644 domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.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 25a32f91ed..45044583d2 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,10 +1,11 @@ package com.tangem.tap.di.domain +import com.tangem.domain.account.supplier.MultiAccountListSupplier 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 +import com.tangem.domain.qrscanning.usecases.ResolveQrSendTargetsUseCase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -35,7 +36,13 @@ internal object QrScanningDomainModule { @Provides @Singleton - fun provideClassifyQrCodeUseCase(repository: QrScanningEventsRepository): ClassifyQrCodeUseCase { - return ClassifyQrCodeUseCase(repository) + fun provideResolveQrSendTargetsUseCase( + multiAccountListSupplier: MultiAccountListSupplier, + qrScanningEventsRepository: QrScanningEventsRepository, + ): ResolveQrSendTargetsUseCase { + return ResolveQrSendTargetsUseCase( + multiAccountListSupplier = multiAccountListSupplier, + qrScanningEventsRepository = qrScanningEventsRepository, + ) } } \ 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 9fd1b269d8..62ff974e39 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,7 @@ package com.tangem.data.qrscanning.di +import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser +import com.tangem.data.qrscanning.parser.Eip681PaymentUriParser import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository @@ -16,10 +18,14 @@ internal object QrScanningDataModule { @Provides @Singleton fun provideQrScanningEventsRepository(): QrScanningEventsRepository { + val blockchainDataProvider = QrContentClassifierParser.DefaultBlockchainDataProvider() return DefaultQrScanningEventsRepository( qrContentClassifierParser = QrContentClassifierParser( - QrContentClassifierParser.DefaultBlockchainDataProvider - (), + blockchainDataProvider = blockchainDataProvider, + paymentUriParsers = setOf( + Eip681PaymentUriParser(blockchainDataProvider), + Bip321PaymentUriParser(blockchainDataProvider), + ), ), ) } diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt new file mode 100644 index 0000000000..fc33495e08 --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt @@ -0,0 +1,54 @@ +package com.tangem.data.qrscanning.parser + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent + +internal class Bip321PaymentUriParser( + private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider, +) : PaymentUriParser { + + override fun parse( + qrCode: String, + coins: List, + allCurrencies: List, + ): PaymentUriParser.ParseResult { + val schemeAndRest = extractSchemeAndRest(qrCode, coins) + ?: return PaymentUriParser.ParseResult.NotRecognized + val (matchingCoins, withoutScheme) = schemeAndRest + + val parsed = QrSentUriParser().parse(withoutScheme) + ?: return PaymentUriParser.ParseResult.RecognizedButNoMatch + + val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() + val matchingCurrencies = allCurrencies.filter { it.network.id in matchingNetworkIds } + if (matchingCurrencies.isEmpty()) return PaymentUriParser.ParseResult.RecognizedButNoMatch + + return PaymentUriParser.ParseResult.Success( + ClassifiedQrContent.PaymentUri( + address = parsed.address, + amount = parsed.amount, + memo = parsed.memo, + matchingCurrencies = matchingCurrencies, + ), + ) + } + + private fun extractSchemeAndRest( + qrCode: String, + coins: List, + ): Pair, String>? { + for (coin in coins) { + val schemes = blockchainDataProvider.getShareSchemes(coin.network) + for (scheme in schemes) { + if (qrCode.startsWith(scheme, ignoreCase = true)) { + val withoutScheme = qrCode.removeRange(0, scheme.length) + val allMatchingCoins = coins.filter { c -> + blockchainDataProvider.getShareSchemes(c.network).any { it.equals(scheme, ignoreCase = true) } + } + return allMatchingCoins to withoutScheme + } + } + } + return null + } +} \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt new file mode 100644 index 0000000000..3bc83d2a7c --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt @@ -0,0 +1,149 @@ +package com.tangem.data.qrscanning.parser + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import java.math.BigDecimal +import java.math.MathContext + +internal class Eip681PaymentUriParser( + private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider, +) : PaymentUriParser { + + override fun parse( + qrCode: String, + coins: List, + allCurrencies: List, + ): PaymentUriParser.ParseResult { + if (!qrCode.startsWith(SCHEME)) return PaymentUriParser.ParseResult.NotRecognized + + val withoutScheme = qrCode.removePrefix(SCHEME) + val parsed = parseEip681(withoutScheme) ?: return PaymentUriParser.ParseResult.NotRecognized + + val matchingCoins = findMatchingCoins(parsed.chainId, coins) + if (matchingCoins.isEmpty()) return PaymentUriParser.ParseResult.RecognizedButNoMatch + + val result = if (parsed.functionName == FUNCTION_TRANSFER) { + resolveErc20Transfer(parsed, matchingCoins, allCurrencies) + } else { + resolveNativeTransfer(parsed, matchingCoins) + } + return if (result != null) { + PaymentUriParser.ParseResult.Success(result) + } else { + PaymentUriParser.ParseResult.RecognizedButNoMatch + } + } + + private fun resolveNativeTransfer( + parsed: Eip681Result, + matchingCoins: List, + ): ClassifiedQrContent.PaymentUri? { + val valueWei = parsed.params[PARAM_VALUE]?.toBigDecimalOrNull() + + if (matchingCoins.isEmpty()) return null + + val decimals = matchingCoins.first().decimals + val amount = valueWei?.fromSmallestUnit(decimals) + + return ClassifiedQrContent.PaymentUri( + address = parsed.targetAddress, + amount = amount, + memo = null, + matchingCurrencies = matchingCoins, + ) + } + + private fun resolveErc20Transfer( + parsed: Eip681Result, + matchingCoins: List, + allCurrencies: List, + ): ClassifiedQrContent.PaymentUri? { + val recipient = parsed.params[PARAM_ADDRESS] ?: return null + val contractAddress = parsed.targetAddress + + val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() + + val tokens = allCurrencies.filterIsInstance() + + val token = tokens + .firstOrNull { token -> + token.network.id in matchingNetworkIds && + token.contractAddress.equals(contractAddress, ignoreCase = true) + } ?: return null + + val rawAmount = parsed.params[PARAM_UINT256]?.toBigDecimalOrNull() + val amount = rawAmount?.fromSmallestUnit(token.decimals) + + return ClassifiedQrContent.PaymentUri( + address = recipient, + amount = amount, + memo = null, + matchingCurrencies = listOf(token), + ) + } + + private fun findMatchingCoins(chainId: Long?, coins: List): List { + if (chainId == null) { + return coins.filter { coin -> + blockchainDataProvider.getShareSchemes(coin.network).any { it.startsWith(SCHEME) } + } + } + return coins.filter { coin -> + blockchainDataProvider.getChainId(coin.network) == chainId + } + } + + private fun parseEip681(withoutScheme: String): Eip681Result? { + val match = URI_REGEX.matchEntire(withoutScheme) ?: return null + + val targetAddress = match.groupValues[GROUP_ADDRESS].ifBlank { return null } + val pathChainId = match.groupValues[GROUP_CHAIN_ID].toLongOrNull() + val functionName = match.groupValues[GROUP_FUNCTION].ifBlank { null } + val queryString = match.groupValues[GROUP_QUERY] + + val params = parseQueryParams(queryString) + val chainId = pathChainId ?: params[PARAM_CHAIN_ID]?.toLongOrNull() + + return Eip681Result( + targetAddress = targetAddress, + chainId = chainId, + functionName = functionName, + params = params, + ) + } + + private fun parseQueryParams(query: String): Map { + if (query.isBlank()) return emptyMap() + return query.split('&').mapNotNull { param -> + val parts = param.split('=', limit = 2) + if (parts.size == 2) parts[0] to parts[1] else null + }.toMap() + } + + private fun BigDecimal.fromSmallestUnit(decimals: Int): BigDecimal { + if (decimals == 0) return this + return this.divide(BigDecimal.TEN.pow(decimals), MathContext.DECIMAL128) + } + + private data class Eip681Result( + val targetAddress: String, + val chainId: Long?, + val functionName: String?, + val params: Map, + ) + + private companion object { + // ethereum:
[@][/][?] + val URI_REGEX = Regex("""^([^@/?]+)(?:@(\d+))?(?:/([^?]+))?(?:\?(.+))?$""") + const val SCHEME = "ethereum:" + const val FUNCTION_TRANSFER = "transfer" + const val PARAM_VALUE = "value" + const val PARAM_ADDRESS = "address" + const val PARAM_UINT256 = "uint256" + const val PARAM_CHAIN_ID = "chainId" + const val GROUP_ADDRESS = 1 + const val GROUP_CHAIN_ID = 2 + const val GROUP_FUNCTION = 3 + const val GROUP_QUERY = 4 + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..8ade342f92 --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriParser.kt @@ -0,0 +1,20 @@ +package com.tangem.data.qrscanning.parser + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent + +internal interface PaymentUriParser { + + fun parse(qrCode: String, coins: List, allCurrencies: List): ParseResult + + sealed class ParseResult { + /** URI format not recognized by this parser. */ + data object NotRecognized : ParseResult() + + /** URI format recognized but no matching currencies found. */ + data object RecognizedButNoMatch : ParseResult() + + /** Successfully parsed with matching currencies. */ + data class Success(val content: ClassifiedQrContent.PaymentUri) : ParseResult() + } +} \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt index 7a881c2a6f..161619037d 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 @@ -8,7 +8,7 @@ import java.net.URLDecoder internal class QrContentClassifierParser( private val blockchainDataProvider: BlockchainDataProvider, - private val paymentUriParser: QrSentUriParser = QrSentUriParser(), + private val paymentUriParsers: Set, ) { fun parse(qrCode: String, userCurrencies: List): ClassifiedQrContent { @@ -23,14 +23,19 @@ internal class QrContentClassifierParser( 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) + 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.NotRecognized -> Unit } - if (matchingCurrencies.isNotEmpty()) { + val matchingNetworkIds = uniqueCoins + .filter { coin -> blockchainDataProvider.validateAddress(coin.network, qrCode) } + .map { it.network.id } + .toSet() + + if (matchingNetworkIds.isNotEmpty()) { + val matchingCurrencies = userCurrencies.filter { it.network.id in matchingNetworkIds } return ClassifiedQrContent.PlainAddress( address = qrCode, matchingCurrencies = matchingCurrencies, @@ -40,29 +45,21 @@ internal class QrContentClassifierParser( 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 tryParsePaymentUri( + qrCode: String, + coins: List, + allCurrencies: List, + ): PaymentUriParser.ParseResult { + return paymentUriParsers.firstNotNullOfOrNull { parser -> + parser.parse(qrCode, coins, allCurrencies).takeUnless { it is PaymentUriParser.ParseResult.NotRecognized } + } ?: PaymentUriParser.ParseResult.NotRecognized } 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 uriParser = QrSentUriParser() + val uriParam = uriParser.extractParameters(qrCode)[PARAM_URI] ?: return false val decodedUri = runCatching { URLDecoder.decode( uriParam, QrSentUriParser.CHARSET_UTF8, @@ -73,6 +70,7 @@ internal class QrContentClassifierParser( internal interface BlockchainDataProvider { fun getShareSchemes(network: Network): List fun validateAddress(network: Network, address: String): Boolean + fun getChainId(network: Network): Long? } internal class DefaultBlockchainDataProvider : BlockchainDataProvider { @@ -83,6 +81,10 @@ internal class QrContentClassifierParser( override fun validateAddress(network: Network, address: String): Boolean { return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false) } + + override fun getChainId(network: Network): Long? { + return runCatching { network.toBlockchain().getChainId()?.toLong() }.getOrNull() + } } private companion object { diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt new file mode 100644 index 0000000000..63a91fe88e --- /dev/null +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt @@ -0,0 +1,269 @@ +package com.tangem.data.qrscanning + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser +import com.tangem.data.qrscanning.parser.PaymentUriParser +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 Bip321PaymentUriParserTest { + + private val blockchainDataProvider = mockk { + every { getShareSchemes(any()) } returns emptyList() + } + private val parser = Bip321PaymentUriParser(blockchainDataProvider) + + // region Basic parsing + + @Test + fun `bitcoin URI with address and amount`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(result.amount!!.compareTo(BigDecimal("0.5"))).isEqualTo(0) + assertThat(result.memo).isNull() + assertThat(result.matchingCurrencies).containsExactly(bitcoinCoin) + } + + @Test + fun `bitcoin URI with address only`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(result.amount).isNull() + assertThat(result.memo).isNull() + } + + @Test + fun `bitcoin URI with amount and message`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.23&message=Donation", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assertThat(result.amount!!.compareTo(BigDecimal("1.23"))).isEqualTo(0) + assertThat(result.memo).isEqualTo("Donation") + } + + @Test + fun `bitcoin URI with label and message`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?label=Satoshi&message=Payment", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.memo).isEqualTo("Payment") + } + + // endregion + + // region Scheme matching + + @Test + fun `litecoin URI matches litecoin coin`() { + every { blockchainDataProvider.getShareSchemes(litecoinCoin.network) } returns listOf("litecoin:") + + val result = parser.parse( + qrCode = "litecoin:LcHKx4Tt97hnGgR3CRUiB1gSQ3F8wMozLj?amount=10", + coins = listOf(litecoinCoin), + allCurrencies = listOf(litecoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("LcHKx4Tt97hnGgR3CRUiB1gSQ3F8wMozLj") + assertThat(result.amount!!.compareTo(BigDecimal("10"))).isEqualTo(0) + assertThat(result.matchingCurrencies).containsExactly(litecoinCoin) + } + + @Test + fun `no matching scheme returns NotRecognized`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val result = parser.parse( + qrCode = "dogecoin:DAddress?amount=100", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + @Test + fun `ethereum scheme matches as Success`() { + every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:") + + val result = parser.parse( + qrCode = "ethereum:0xRecipient?value=1000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ).asSuccess() + + assertThat(result).isNotNull() + } + + @Test + fun `case insensitive scheme matching`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val result = parser.parse( + qrCode = "Bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + } + + // endregion + + // region Includes tokens on matching network + + @Test + fun `includes tokens on matching network`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val btcToken = buildToken("bitcoin", "RUNE", "contractAddr") + + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.01", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin, btcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.matchingCurrencies).containsExactly(bitcoinCoin, btcToken) + } + + // endregion + + // region Edge cases + + @Test + fun `empty qr code returns NotRecognized`() { + val result = parser.parse( + qrCode = "", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + @Test + fun `plain address returns NotRecognized`() { + val result = parser.parse( + qrCode = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + @Test + fun `bitcoin URI with memo param`() { + every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&memo=TestMemo", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.memo).isEqualTo("TestMemo") + } + + // endregion + + // region Helpers + + private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? { + return (this as? PaymentUriParser.ParseResult.Success)?.content + } + + private val bitcoinCoin = buildCoin("bitcoin", decimals = 8) + private val litecoinCoin = buildCoin("litecoin", decimals = 8) + private val ethereumCoin = buildCoin("ethereum", decimals = 18) + + private fun buildCoin(rawNetworkId: String, decimals: Int): 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 = decimals, + iconUrl = null, + isCustom = false, + ) + } + + private fun buildToken(rawNetworkId: String, symbol: String, contractAddress: String): CryptoCurrency.Token { + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress), + ), + network = buildNetwork(rawNetworkId), + name = symbol, + symbol = symbol, + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = contractAddress, + ) + } + + 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/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 new file mode 100644 index 0000000000..b270ef58c1 --- /dev/null +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt @@ -0,0 +1,305 @@ +package com.tangem.data.qrscanning + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.qrscanning.parser.Eip681PaymentUriParser +import com.tangem.data.qrscanning.parser.PaymentUriParser +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 Eip681PaymentUriParserTest { + + private val blockchainDataProvider = mockk { + every { getShareSchemes(any()) } returns emptyList() + every { getChainId(any()) } returns null + } + private val parser = Eip681PaymentUriParser(blockchainDataProvider) + + // region Native transfer + + @Test + fun `native transfer with chain_id and value`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val result = parser.parse( + qrCode = "ethereum:0xRecipient@1?value=1500000000000000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("0xRecipient") + assertThat(result.amount!!.compareTo(BigDecimal("1.5"))).isEqualTo(0) + assertThat(result.memo).isNull() + assertThat(result.matchingCurrencies).containsExactly(ethereumCoin) + } + + @Test + fun `native transfer without value`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val result = parser.parse( + qrCode = "ethereum:0xRecipient@1", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("0xRecipient") + assertThat(result.amount).isNull() + } + + @Test + fun `native transfer without chain_id falls back to scheme matching`() { + every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:") + + val result = parser.parse( + qrCode = "ethereum:0xRecipient?value=1000000000000000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("0xRecipient") + assertThat(result.amount!!.compareTo(BigDecimal("1"))).isEqualTo(0) + } + + @Test + fun `native transfer includes only coins, not tokens`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + + val result = parser.parse( + qrCode = "ethereum:0xRecipient@1?value=1000000000000000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin, usdcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.matchingCurrencies).containsExactly(ethereumCoin) + } + + // endregion + + // region ERC-20 transfer + + @Test + fun `ERC-20 transfer with contract, recipient and amount`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + + val result = parser.parse( + qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xRecipient&uint256=1000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin, usdcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("0xRecipient") + assertThat(result.amount!!.compareTo(BigDecimal("1"))).isEqualTo(0) + assertThat(result.matchingCurrencies).containsExactly(usdcToken) + } + + @Test + fun `ERC-20 transfer without amount`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + + val result = parser.parse( + qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xRecipient", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin, usdcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("0xRecipient") + assertThat(result.amount).isNull() + assertThat(result.matchingCurrencies).containsExactly(usdcToken) + } + + @Test + fun `ERC-20 transfer with unknown token returns RecognizedButNoMatch`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val result = parser.parse( + qrCode = "ethereum:0xUnknownContract@1/transfer?address=0xRecipient&uint256=1000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedButNoMatch::class.java) + } + + @Test + fun `ERC-20 transfer without address param returns RecognizedButNoMatch`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + + val result = parser.parse( + qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?uint256=1000000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin, usdcToken), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedButNoMatch::class.java) + } + + // endregion + + @Test + fun `ERC-20 transfer with chainId as query param`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val usdtToken = buildToken("ethereum", "USDT", "0xdAC17F958D2ee523a2206206994597C13D831ec7") + + val result = parser.parse( + qrCode = "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7/transfer?address=0x3D709aC89d780312677519c3AfC13f390C819531&uint256=30000000&chainId=1", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin, usdtToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("0x3D709aC89d780312677519c3AfC13f390C819531") + assertThat(result.amount!!.compareTo(BigDecimal("30"))).isEqualTo(0) + assertThat(result.matchingCurrencies).containsExactly(usdtToken) + } + + // region Chain ID matching + + @Test + fun `chain_id mismatch returns RecognizedButNoMatch`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + + val result = parser.parse( + qrCode = "ethereum:0xRecipient@137?value=1000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedButNoMatch::class.java) + } + + @Test + fun `chain_id matches correct network among multiple`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L + every { blockchainDataProvider.getChainId(polygonCoin.network) } returns 137L + + val result = parser.parse( + qrCode = "ethereum:0xRecipient@137?value=1000000000000000000", + coins = listOf(ethereumCoin, polygonCoin), + allCurrencies = listOf(ethereumCoin, polygonCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.matchingCurrencies).containsExactly(polygonCoin) + } + + // endregion + + // region Non-ethereum schemes + + @Test + fun `non-ethereum scheme returns NotRecognized`() { + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5", + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + @Test + fun `empty qr code returns NotRecognized`() { + val result = parser.parse( + qrCode = "", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + @Test + fun `ethereum scheme with blank address returns NotRecognized`() { + val result = parser.parse( + qrCode = "ethereum:?value=1000", + coins = listOf(ethereumCoin), + allCurrencies = listOf(ethereumCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + // endregion + + // region Helpers + + private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? { + return (this as? PaymentUriParser.ParseResult.Success)?.content + } + + private val bitcoinCoin = buildCoin("bitcoin", decimals = 8) + private val ethereumCoin = buildCoin("ethereum", decimals = 18) + private val polygonCoin = buildCoin("polygon", decimals = 18) + + private fun buildCoin(rawNetworkId: String, decimals: Int): 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 = decimals, + iconUrl = null, + isCustom = false, + ) + } + + private fun buildToken(rawNetworkId: String, symbol: String, contractAddress: String): CryptoCurrency.Token { + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress), + ), + network = buildNetwork(rawNetworkId), + name = symbol, + symbol = symbol, + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = contractAddress, + ) + } + + 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/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 b4dc4c9976..c8b289207f 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 @@ -1,6 +1,7 @@ package com.tangem.data.qrscanning import com.google.common.truth.Truth.assertThat +import com.tangem.data.qrscanning.parser.PaymentUriParser import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -15,8 +16,15 @@ internal class QrContentClassifierTest { private val blockchainDataProvider = mockk { every { getShareSchemes(any()) } returns emptyList() every { validateAddress(any(), any()) } returns false + every { getChainId(any()) } returns null } - private val classifier = QrContentClassifierParser(blockchainDataProvider) + private val paymentUriParser = mockk { + every { parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.NotRecognized + } + private val classifier = QrContentClassifierParser( + blockchainDataProvider = blockchainDataProvider, + paymentUriParsers = setOf(paymentUriParser), + ) // region WalletConnect @@ -69,91 +77,37 @@ internal class QrContentClassifierTest { // endregion - // region PaymentUri + // region PaymentUri delegation @Test - fun `Bitcoin BIP-021 URI with amount is parsed`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") + fun `PaymentUri is returned when parser matches`() { + val expectedUri = ClassifiedQrContent.PaymentUri( + address = "0xRecipient", + amount = BigDecimal("1.5"), + memo = null, + matchingCurrencies = listOf(ethereumCoin), + ) + every { paymentUriParser.parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.Success(expectedUri) - val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5" - val result = classifier.parse(qr, listOf(bitcoinCoin, ethereumCoin)) + val result = classifier.parse("ethereum:0xRecipient@1?value=1500000000000000000", listOf(ethereumCoin)) + + assertThat(result).isEqualTo(expectedUri) + } + + @Test + fun `PaymentUri takes priority over plain address match`() { + val expectedUri = ClassifiedQrContent.PaymentUri( + address = "0xRecipient", + amount = null, + memo = null, + matchingCurrencies = listOf(ethereumCoin), + ) + every { paymentUriParser.parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.Success(expectedUri) + every { blockchainDataProvider.validateAddress(ethereumCoin.network, any()) } returns true + + val result = classifier.parse("ethereum:0xRecipient", listOf(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 @@ -186,6 +140,20 @@ internal class QrContentClassifierTest { assertThat(plain.matchingCurrencies).hasSize(2) } + @Test + fun `PlainAddress includes tokens on matching networks`() { + val address = "0x1234567890abcdef1234567890abcdef12345678" + every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true + + val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + + val result = classifier.parse(address, listOf(ethereumCoin, usdcToken)) + + assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java) + val plain = result as ClassifiedQrContent.PlainAddress + assertThat(plain.matchingCurrencies).containsExactly(ethereumCoin, usdcToken) + } + // endregion // region Unknown @@ -212,55 +180,15 @@ internal class QrContentClassifierTest { 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", - ) + fun `Tokens alone without Coins cannot match addresses`() { + val token = buildToken("ethereum", "USDT", "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 @@ -285,6 +213,23 @@ internal class QrContentClassifierTest { ) } + private fun buildToken(rawNetworkId: String, symbol: String, contractAddress: String): CryptoCurrency.Token { + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress), + ), + network = buildNetwork(rawNetworkId), + name = symbol, + symbol = symbol, + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = contractAddress, + ) + } + private fun buildNetwork(rawNetworkId: String): Network { return Network( id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), diff --git a/domain/qr-scanning/build.gradle.kts b/domain/qr-scanning/build.gradle.kts index e50263fe06..564ec92b53 100644 --- a/domain/qr-scanning/build.gradle.kts +++ b/domain/qr-scanning/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { /** Domain */ api(projects.domain.models) + implementation(projects.domain.account) implementation(projects.domain.qrScanning.models) implementation(projects.domain.tokens.models) diff --git a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt index 4c482b56a5..dab8a60259 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 @@ -8,10 +8,10 @@ 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?, + val matchingCurrencies: List, ) : ClassifiedQrContent() data class PlainAddress( 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 new file mode 100644 index 0000000000..d802a5f2ec --- /dev/null +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt @@ -0,0 +1,44 @@ +package com.tangem.domain.qrscanning.models + +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import java.math.BigDecimal + +sealed class QrSendTarget { + + /** Single match — navigate directly to Send */ + data class Single( + val userWalletId: UserWalletId, + val currency: CryptoCurrency, + val address: String, + val amount: BigDecimal?, + val memo: String?, + ) : QrSendTarget() + + /** Multiple matches — data for bottom sheet selection */ + data class Multiple( + val address: String, + val amount: BigDecimal?, + val memo: String?, + val walletGroups: List, + ) : QrSendTarget() { + + data class WalletGroup( + val userWalletId: UserWalletId, + val walletName: String, + val accounts: List, + ) + + data class AccountGroup( + val accountId: AccountId, + val accountName: AccountName, + val currencies: List, + ) + } + + data class WalletConnect(val uri: String) : QrSendTarget() + + data class Unknown(val raw: String) : QrSendTarget() +} \ 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 deleted file mode 100644 index 16180975e0..0000000000 --- a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ClassifyQrCodeUseCase.kt +++ /dev/null @@ -1,13 +0,0 @@ -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/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 new file mode 100644 index 0000000000..2d436c4ceb --- /dev/null +++ b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt @@ -0,0 +1,153 @@ +package com.tangem.domain.qrscanning.usecases + +import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository +import java.math.BigDecimal +import com.tangem.domain.qrscanning.models.QrSendTarget + +class ResolveQrSendTargetsUseCase( + private val multiAccountListSupplier: MultiAccountListSupplier, + private val qrScanningEventsRepository: QrScanningEventsRepository, +) { + + suspend operator fun invoke(qrCode: String): QrSendTarget { + val allAccountLists = multiAccountListSupplier.getSyncOrNull(Unit).orEmpty() + + val currencyEntries = allAccountLists.flatMap { accountList -> + accountList.accounts + .filterIsInstance() + .flatMap { account -> + account.cryptoCurrencies.map { currency -> + currency to CurrencyLocation( + userWalletId = accountList.userWalletId, + walletName = accountList.userWalletId.stringValue, + accountId = account.accountId, + accountName = account.accountName, + ) + } + } + } + + val allCurrencies = currencyEntries.map { it.first } + val currencyLocations = currencyEntries.groupBy( + keySelector = { it.first.id }, + valueTransform = { it.second }, + ) + + val classified = qrScanningEventsRepository.classify(qrCode, allCurrencies) + + return resolve(classified, currencyLocations) + } + + private fun resolve( + classified: ClassifiedQrContent, + currencyLocations: Map>, + ): QrSendTarget { + return when (classified) { + is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri) + is ClassifiedQrContent.Unknown -> QrSendTarget.Unknown(classified.raw) + is ClassifiedQrContent.PlainAddress -> resolveAddressTarget( + address = classified.address, + amount = null, + memo = null, + matchingCurrencies = classified.matchingCurrencies, + currencyLocations = currencyLocations, + ) + is ClassifiedQrContent.PaymentUri -> resolveAddressTarget( + address = classified.address, + amount = classified.amount, + memo = classified.memo, + matchingCurrencies = classified.matchingCurrencies, + currencyLocations = currencyLocations, + ) + } + } + + private fun resolveAddressTarget( + address: String, + amount: BigDecimal?, + memo: String?, + matchingCurrencies: List, + currencyLocations: Map>, + ): QrSendTarget { + val walletGroups = buildWalletGroups(matchingCurrencies, currencyLocations) + + val singleGroup = walletGroups.singleOrNull() + val singleCurrency = singleGroup?.accounts?.singleOrNull()?.currencies?.singleOrNull() + + return if (singleGroup != null && singleCurrency != null) { + QrSendTarget.Single( + userWalletId = singleGroup.userWalletId, + currency = singleCurrency, + address = address, + amount = amount, + memo = memo, + ) + } else { + QrSendTarget.Multiple( + address = address, + amount = amount, + memo = memo, + walletGroups = walletGroups, + ) + } + } + + private fun buildWalletGroups( + matchingCurrencies: List, + currencyLocations: Map>, + ): List { + val walletMap = linkedMapOf() + val uniqueCurrencies = matchingCurrencies.distinctBy { it.id } + + for (currency in uniqueCurrencies) { + val locations = currencyLocations[currency.id] ?: continue + for (location in locations) { + val walletInfo = walletMap.getOrPut(location.userWalletId) { + WalletInfo(location.walletName, linkedMapOf()) + } + val accountInfo = walletInfo.accounts.getOrPut(location.accountId) { + AccountInfo(location.accountName, mutableListOf()) + } + accountInfo.currencies.add(currency) + } + } + + return walletMap.map { (walletId, walletInfo) -> + QrSendTarget.Multiple.WalletGroup( + userWalletId = walletId, + walletName = walletInfo.walletName, + accounts = walletInfo.accounts.map { (accountId, accountInfo) -> + QrSendTarget.Multiple.AccountGroup( + accountId = accountId, + accountName = accountInfo.accountName, + currencies = accountInfo.currencies, + ) + }, + ) + } + } + + private data class CurrencyLocation( + val userWalletId: UserWalletId, + val walletName: String, + val accountId: AccountId, + val accountName: AccountName, + ) + + private class WalletInfo( + val walletName: String, + val accounts: LinkedHashMap, + ) + + private class AccountInfo( + val accountName: AccountName, + val currencies: MutableList, + ) +} \ No newline at end of file diff --git a/domain/wallets/build.gradle.kts b/domain/wallets/build.gradle.kts index f11102091a..47d5fd6e1c 100644 --- a/domain/wallets/build.gradle.kts +++ b/domain/wallets/build.gradle.kts @@ -34,6 +34,11 @@ dependencies { implementation(projects.domain.notifications.models) implementation(projects.domain.demo.models) implementation(projects.domain.hotWallet) + implementation(projects.domain.qrScanning) + // endregion + + // region Domain modules + implementation(projects.domain.qrScanning.models) // endregion implementation(projects.common) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt index 5174229910..db07b4492e 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -267,29 +267,45 @@ internal class SendModel @Inject constructor( private suspend fun prepareTransferTransaction(): Either { val predefinedValues = predefinedValues val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value - return if (predefinedValues is PredefinedValues.Content.Deeplink) { - val predefinedAmount = predefinedValues.amount.parseBigDecimalOrNull() - createTransferTransactionUseCase( - amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus) ?: error("Invalid amount"), - memo = predefinedValues.memo, - destination = predefinedValues.address, - userWalletId = userWallet.walletId, - network = cryptoCurrency.network, - ) - } else { - val destinationUM = uiState.value.destinationUM as? DestinationUM.Content ?: error("Invalid destination") - val amountUM = uiState.value.amountUM as? AmountState.Data ?: error("Invalid amount") - val enteredDestinationAddress = destinationUM.addressTextField.actualAddress - val enteredMemo = destinationUM.memoTextField?.value - val enteredAmount = amountUM.amountTextField.cryptoAmount.value ?: error("Invalid amount") + return when (predefinedValues) { + is PredefinedValues.Content.Deeplink -> { + val predefinedAmount = predefinedValues.amount.parseBigDecimalOrNull() + createTransferTransactionUseCase( + amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus) + ?: error("Invalid amount"), + memo = predefinedValues.memo, + destination = predefinedValues.address, + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ) + } + is PredefinedValues.Content.QrCode -> { + val predefinedAmount = predefinedValues.amount?.parseBigDecimalOrNull() + createTransferTransactionUseCase( + amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus) + ?: error("Invalid amount"), + memo = predefinedValues.memo, + destination = predefinedValues.address, + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ) + } + PredefinedValues.Empty -> { + val destinationUM = uiState.value.destinationUM as? DestinationUM.Content + ?: error("Invalid destination") + val amountUM = uiState.value.amountUM as? AmountState.Data ?: error("Invalid amount") + val enteredDestinationAddress = destinationUM.addressTextField.actualAddress + val enteredMemo = destinationUM.memoTextField?.value + val enteredAmount = amountUM.amountTextField.cryptoAmount.value ?: error("Invalid amount") - createTransferTransactionUseCase( - amount = enteredAmount.convertToSdkAmount(cryptoCurrencyStatus), - memo = enteredMemo, - destination = enteredDestinationAddress, - userWalletId = userWallet.walletId, - network = cryptoCurrency.network, - ) + createTransferTransactionUseCase( + amount = enteredAmount.convertToSdkAmount(cryptoCurrencyStatus), + memo = enteredMemo, + destination = enteredDestinationAddress, + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ) + } } } @@ -348,6 +364,12 @@ internal class SendModel @Inject constructor( memo = params.tag, transactionId = predefinedTxId, ) + } else if (predefinedAddress != null) { + PredefinedValues.Content.QrCode( + amount = predefinedAmount, + address = predefinedAddress, + memo = params.tag, + ) } else { PredefinedValues.Empty } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index e2c9a282c5..73df61850a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -96,7 +96,7 @@ internal class SendDestinationModel @Inject constructor( ), ) val params = params as? DestinationBlockParams - val predefinedValues = params?.predefinedValues as? PredefinedValues.Content.Deeplink + val predefinedValues = params?.predefinedValues as? PredefinedValues.Content if (predefinedValues?.address != null) { _uiState.update( SendDestinationPredefinedStateTransformer( 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 fcc3750444..dd76e89962 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 @@ -5,6 +5,7 @@ import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.analytics.utils.TrackingContextProxy @@ -25,13 +26,12 @@ 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.qrscanning.models.QrSendTarget 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.qrscanning.usecases.ResolveQrSendTargetsUseCase import com.tangem.domain.settings.* import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase import com.tangem.domain.wallets.usecase.* @@ -40,7 +40,6 @@ 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 @@ -115,8 +114,7 @@ internal class WalletModel @Inject constructor( private val walletFeatureToggles: WalletFeatureToggles, private val listenToQrScanningUseCase: ListenToQrScanningUseCase, private val wcPairService: WcPairService, - private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val classifyQrCodeUseCase: ClassifyQrCodeUseCase, + private val resolveQrSendTargetsUseCase: ResolveQrSendTargetsUseCase, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -752,16 +750,8 @@ internal class WalletModel @Inject constructor( } 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 -> { + when (val target = resolveQrSendTargetsUseCase(qrCode)) { + is QrSendTarget.WalletConnect -> { val source = when (resultSource) { QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD QrResultSource.CAMERA, @@ -770,34 +760,25 @@ internal class WalletModel @Inject constructor( } wcPairService.pair( WcPairRequest( - userWalletId = userWalletId, - uri = classified.uri, + userWalletId = stateHolder.getSelectedWalletId(), + uri = target.uri, source = source, ), ) } - is ClassifiedQrContent.PaymentUri -> { + is QrSendTarget.Single -> { innerWalletRouter.openSend( - userWalletId = userWalletId, - currency = classified.currency, - address = classified.address, - amount = classified.amount?.toPlainString(), - tag = classified.memo, + userWalletId = target.userWalletId, + currency = target.currency, + address = target.address, + amount = target.amount?.parseBigDecimal(target.currency.decimals), + tag = target.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 QrSendTarget.Multiple -> { + // TODO: [REDACTED_TASK_KEY] Bottom sheet: Wallets (dropdown) → Accounts → Tokens } - is ClassifiedQrContent.Unknown -> { + is QrSendTarget.Unknown -> { // TODO: [REDACTED_TASK_KEY] Error handling for unsupported and invalid QR codes } }