Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-13 12:51:12 +03:00
parent 158fd355de
commit 473aeee675
18 changed files with 1176 additions and 226 deletions

View file

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

View file

@ -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<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): 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<CryptoCurrency.Coin>,
): Pair<List<CryptoCurrency.Coin>, 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
}
}

View file

@ -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<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): 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<CryptoCurrency.Coin>,
): 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<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): 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<CryptoCurrency.Token>()
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<CryptoCurrency.Coin>): List<CryptoCurrency.Coin> {
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<String, String> {
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<String, String>,
)
private companion object {
// ethereum:<address>[@<chainId>][/<function>][?<params>]
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
}
}

View file

@ -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<CryptoCurrency.Coin>, allCurrencies: List<CryptoCurrency>): 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()
}
}

View file

@ -8,7 +8,7 @@ import java.net.URLDecoder
internal class QrContentClassifierParser(
private val blockchainDataProvider: BlockchainDataProvider,
private val paymentUriParser: QrSentUriParser = QrSentUriParser(),
private val paymentUriParsers: Set<PaymentUriParser>,
) {
fun parse(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
@ -23,14 +23,19 @@ internal class QrContentClassifierParser(
val coins = userCurrencies.filterIsInstance<CryptoCurrency.Coin>()
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<CryptoCurrency.Coin>): 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<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): 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<String>
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 {

View file

@ -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<QrContentClassifierParser.BlockchainDataProvider> {
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
}

View file

@ -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<QrContentClassifierParser.BlockchainDataProvider> {
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
}

View file

@ -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<QrContentClassifierParser.BlockchainDataProvider> {
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<PaymentUriParser> {
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),