Updated on 2026-08-14

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

View file

@ -17,11 +17,17 @@ internal class Bip321PaymentUriParser(
val (matchingCoins, withoutScheme) = schemeAndRest
val parsed = QrSentUriParser().parse(withoutScheme)
?: return PaymentUriParser.ParseResult.RecognizedButNoMatch
?: return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.Unrecognized(qrCode),
)
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
val matchingCurrencies = allCurrencies.filter { it.network.id in matchingNetworkIds }
if (matchingCurrencies.isEmpty()) return PaymentUriParser.ParseResult.RecognizedButNoMatch
if (matchingCurrencies.isEmpty()) {
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.UnsupportedNetwork,
)
}
return PaymentUriParser.ParseResult.Success(
ClassifiedQrContent.PaymentUri(

View file

@ -23,10 +23,17 @@ internal class Eip681PaymentUriParser(
val matchingCoins = findMatchingCoins(parsed.chainId, coins)
if (matchingCoins.isEmpty()) {
return PaymentUriParser.ParseResult.RecognizedButNoMatch
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.UnsupportedNetwork,
)
}
val result = if (parsed.functionName == FUNCTION_TRANSFER) {
if (PARAM_ADDRESS !in parsed.params) {
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.Unrecognized(qrCode),
)
}
resolveErc20Transfer(parsed, matchingCoins, allCurrencies)
} else {
resolveNativeTransfer(parsed, matchingCoins, allCurrencies)
@ -34,7 +41,9 @@ internal class Eip681PaymentUriParser(
return if (result != null) {
PaymentUriParser.ParseResult.Success(result)
} else {
PaymentUriParser.ParseResult.RecognizedButNoMatch
PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.UnsupportedNetwork,
)
}
}

View file

@ -11,8 +11,8 @@ internal interface PaymentUriParser {
/** URI format not recognized by this parser. */
data object NotRecognized : ParseResult()
/** URI format recognized but no matching currencies found. */
data object RecognizedButNoMatch : ParseResult()
/** URI format recognized but resulted in an error. */
data class RecognizedError(val error: ClassifiedQrContent.Error) : ParseResult()
/** Successfully parsed with matching currencies. */
data class Success(val content: ClassifiedQrContent.PaymentUri) : ParseResult()

View file

@ -1,5 +1,6 @@
package com.tangem.data.qrscanning.parser
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
@ -25,7 +26,7 @@ internal class QrContentClassifierParser(
when (val paymentUriResult = tryParsePaymentUri(qrCode, uniqueCoins, userCurrencies)) {
is PaymentUriParser.ParseResult.Success -> return paymentUriResult.content
is PaymentUriParser.ParseResult.RecognizedButNoMatch -> return ClassifiedQrContent.Unknown(qrCode)
is PaymentUriParser.ParseResult.RecognizedError -> return paymentUriResult.error
is PaymentUriParser.ParseResult.NotRecognized -> Unit
}
@ -35,13 +36,18 @@ internal class QrContentClassifierParser(
if (matchingNetworkIds.isNotEmpty()) {
val matchingCurrencies = userCurrencies.filter { it.network.id in matchingNetworkIds }
return ClassifiedQrContent.PlainAddress(
address = qrCode,
matchingCurrencies = matchingCurrencies,
)
}
return ClassifiedQrContent.Unknown(qrCode)
if (blockchainDataProvider.isSupportedAddress(qrCode)) {
return ClassifiedQrContent.Error.UnsupportedNetwork
}
return ClassifiedQrContent.Error.Unrecognized(qrCode)
}
private fun tryParsePaymentUri(
@ -70,6 +76,7 @@ internal class QrContentClassifierParser(
fun getShareSchemes(network: Network): List<String>
fun validateAddress(network: Network, address: String): Boolean
fun getChainId(network: Network): Long?
fun isSupportedAddress(address: String): Boolean
}
internal class DefaultBlockchainDataProvider : BlockchainDataProvider {
@ -84,6 +91,14 @@ internal class QrContentClassifierParser(
override fun getChainId(network: Network): Long? {
return runCatching { network.toBlockchain().getChainId()?.toLong() }.getOrNull()
}
override fun isSupportedAddress(address: String): Boolean {
return Blockchain.entries
.filter { !it.isTestnet() }
.any { blockchain ->
runCatching { blockchain.validateAddress(address) }.getOrDefault(false)
}
}
}
private companion object {

View file

@ -142,7 +142,7 @@ internal class Eip681PaymentUriParserTest {
}
@Test
fun `ERC-20 transfer with unknown token returns RecognizedButNoMatch`() {
fun `ERC-20 transfer with unknown token returns UnsupportedNetwork error`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val result = parser.parse(
@ -151,11 +151,13 @@ internal class Eip681PaymentUriParserTest {
allCurrencies = listOf(ethereumCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedButNoMatch::class.java)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.UnsupportedNetwork::class.java)
}
@Test
fun `ERC-20 transfer without address param returns RecognizedButNoMatch`() {
fun `ERC-20 transfer without address param returns Unrecognized error`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
@ -166,7 +168,9 @@ internal class Eip681PaymentUriParserTest {
allCurrencies = listOf(ethereumCoin, usdcToken),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedButNoMatch::class.java)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
// endregion
@ -192,7 +196,7 @@ internal class Eip681PaymentUriParserTest {
// region Chain ID matching
@Test
fun `chain_id mismatch returns RecognizedButNoMatch`() {
fun `chain_id mismatch returns UnsupportedNetwork error`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val result = parser.parse(
@ -201,7 +205,9 @@ internal class Eip681PaymentUriParserTest {
allCurrencies = listOf(ethereumCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedButNoMatch::class.java)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.UnsupportedNetwork::class.java)
}
@Test

View file

@ -17,6 +17,7 @@ internal class QrContentClassifierTest {
every { getShareSchemes(any()) } returns emptyList()
every { validateAddress(any(), any()) } returns false
every { getChainId(any()) } returns null
every { isSupportedAddress(any()) } returns false
}
private val paymentUriParser = mockk<PaymentUriParser> {
every { parse(any(), any(), any()) } returns PaymentUriParser.ParseResult.NotRecognized
@ -63,7 +64,7 @@ internal class QrContentClassifierTest {
val result = classifier.parse(url, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
@Test
@ -72,7 +73,7 @@ internal class QrContentClassifierTest {
val result = classifier.parse(url, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
// endregion
@ -162,22 +163,22 @@ internal class QrContentClassifierTest {
fun `Random string returns Unknown`() {
val result = classifier.parse("hello world", listOf(bitcoinCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
assertThat((result as ClassifiedQrContent.Unknown).raw).isEqualTo("hello world")
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
assertThat((result as ClassifiedQrContent.Error.Unrecognized).raw).isEqualTo("hello world")
}
@Test
fun `Empty string returns Unknown`() {
val result = classifier.parse("", listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
@Test
fun `Empty currencies list returns Unknown`() {
val result = classifier.parse("0x1234567890abcdef1234567890abcdef12345678", emptyList())
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
@Test
@ -186,7 +187,7 @@ internal class QrContentClassifierTest {
val result = classifier.parse("0x1234", listOf(token))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
assertThat(result).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
// endregion