Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-10 15:12:44 +03:00
parent 8a4dbe1b4c
commit 8a79e65e2e
18 changed files with 1099 additions and 101 deletions

View file

@ -1,6 +1,7 @@
package com.tangem.tap.di.domain
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import com.tangem.domain.qrscanning.usecases.ClassifyQrCodeUseCase
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
@ -31,4 +32,10 @@ internal object QrScanningDomainModule {
fun provideParseQrCodeUseCase(repository: QrScanningEventsRepository): ParseQrCodeUseCase {
return ParseQrCodeUseCase(repository)
}
@Provides
@Singleton
fun provideClassifyQrCodeUseCase(repository: QrScanningEventsRepository): ClassifyQrCodeUseCase {
return ClassifyQrCodeUseCase(repository)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.data.qrscanning.di
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import dagger.Module
@ -15,6 +16,11 @@ internal object QrScanningDataModule {
@Provides
@Singleton
fun provideQrScanningEventsRepository(): QrScanningEventsRepository {
return DefaultQrScanningEventsRepository()
return DefaultQrScanningEventsRepository(
qrContentClassifierParser = QrContentClassifierParser(
QrContentClassifierParser.DefaultBlockchainDataProvider
(),
),
)
}
}

View file

@ -0,0 +1,94 @@
package com.tangem.data.qrscanning.parser
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import java.net.URLDecoder
internal class QrContentClassifierParser(
private val blockchainDataProvider: BlockchainDataProvider,
private val paymentUriParser: QrSentUriParser = QrSentUriParser(),
) {
fun parse(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
if (qrCode.startsWith(WC_PREFIX)) {
return ClassifiedQrContent.WalletConnect(qrCode)
}
if (isDAppWcUrl(qrCode)) {
return ClassifiedQrContent.WalletConnect(qrCode)
}
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)
}
if (matchingCurrencies.isNotEmpty()) {
return ClassifiedQrContent.PlainAddress(
address = qrCode,
matchingCurrencies = matchingCurrencies,
)
}
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 isDAppWcUrl(qrCode: String): Boolean {
if (!qrCode.startsWith(HTTP_PREFIX) && !qrCode.startsWith(HTTPS_PREFIX)) return false
val uriParam = paymentUriParser.extractParameters(qrCode)[PARAM_URI] ?: return false
val decodedUri = runCatching { URLDecoder.decode(
uriParam,
QrSentUriParser.CHARSET_UTF8,
) }.getOrDefault(uriParam)
return decodedUri.startsWith(WC_PREFIX)
}
internal interface BlockchainDataProvider {
fun getShareSchemes(network: Network): List<String>
fun validateAddress(network: Network, address: String): Boolean
}
internal class DefaultBlockchainDataProvider : BlockchainDataProvider {
override fun getShareSchemes(network: Network): List<String> {
return runCatching { network.toBlockchain().getShareScheme() }.getOrDefault(emptyList())
}
override fun validateAddress(network: Network, address: String): Boolean {
return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false)
}
}
private companion object {
const val HTTP_PREFIX = "http://"
const val HTTPS_PREFIX = "https://"
const val PARAM_URI = "uri"
const val WC_PREFIX = "wc:"
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.data.qrscanning.parser
import java.math.BigDecimal
import java.net.URLDecoder
internal class QrSentUriParser {
data class Result(
val address: String,
val amount: BigDecimal?,
val memo: String?,
val params: Map<String, String>,
)
fun parse(withoutScheme: String): Result? {
val address = withoutScheme.takeWhile {
it != CHAIN_DELIMITER && it != FUNCTION_DELIMITER && it != PARAM_DELIMITER
}
if (address.isBlank()) return null
val params = extractParameters(withoutScheme)
val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull()
val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let {
runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it)
}
return Result(
address = address,
amount = amount,
memo = memo,
params = params,
)
}
fun extractParameters(from: String): Map<String, String> {
val paramsBlock = from.substringAfter(PARAM_DELIMITER, missingDelimiterValue = "")
if (paramsBlock.isBlank()) return emptyMap()
return paramsBlock.split(PARAMS_DELIMITER)
.mapNotNull { param ->
val parts = param.split(PARAM_VALUE_DELIMITER, limit = 2)
if (parts.size == 2) parts[0].lowercase() to parts[1] else null
}
.toMap()
}
companion object {
const val CHAIN_DELIMITER = '@'
const val FUNCTION_DELIMITER = '/'
const val PARAM_DELIMITER = '?'
const val PARAMS_DELIMITER = '&'
const val PARAM_VALUE_DELIMITER = '='
const val PARAM_AMOUNT = "amount"
const val PARAM_MEMO = "memo"
const val PARAM_MESSAGE = "message"
const val PARAM_ADDRESS = "address"
const val PARAM_VALUE = "value"
const val PARAM_UINT256 = "uint256"
const val CHARSET_UTF8 = "UTF-8"
}
}

View file

@ -2,7 +2,10 @@ package com.tangem.data.qrscanning.repository
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.data.qrscanning.parser.QrSentUriParser
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import com.tangem.domain.qrscanning.models.QrResult
import com.tangem.domain.qrscanning.models.RawQrResult
import com.tangem.domain.qrscanning.models.SourceType
@ -14,9 +17,11 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.yield
import java.math.BigDecimal
import java.net.URLDecoder
internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
internal class DefaultQrScanningEventsRepository(
private val qrContentClassifierParser: QrContentClassifierParser,
private val paymentUriParser: QrSentUriParser = QrSentUriParser(),
) : QrScanningEventsRepository {
private data class QrScanningEvent(val qrCode: RawQrResult)
@ -37,120 +42,50 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
override fun parseQrCode(qrCode: String, cryptoCurrency: CryptoCurrency): QrResult {
val withoutSchema = stripSchema(qrCode, cryptoCurrency)
val parsed = paymentUriParser.parse(withoutSchema)
?: return QrResult(address = withoutSchema)
// A poor man's ERC-681 parser: we want to extract only the destination address, and we don't care
// about other parts of the ERC-681 payload string like `chain_id` and/or `function_name`.
//
// We're extracting the destination address by parsing the given string until we meet
// any of the possible string delimiters (@ ? /).
val address = withoutSchema.takeWhile { char ->
char != CHAIN_DELIMITER && char != FUNCTION_DELIMITER && char != PARAM_DELIMITER
}
val result = QrResult(address = parsed.address)
result.amount = parsed.amount
result.memo = parsed.memo
val result = QrResult(address = address)
extractParameters(withoutSchema)
.forEach { entry ->
when (entry.key) {
Parameter.Amount -> {
// According to BIP-0021, the value is specified in decimals. No conversion needed
result.amount = entry.value.parseBigDecimalOrNull()
}
Parameter.Message,
Parameter.Memo,
-> {
result.memo = URLDecoder.decode(entry.value, "UTF-8")
}
Parameter.Address -> {
// If 'address' parameter is exists, then currency must be TOKEN.
// ERC-681: if 'address' parameter exists, currency must be a token,
// and the URI address must match the token's contract address.
parsed.params[QrSentUriParser.PARAM_ADDRESS]?.let { addressValue ->
val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult()
// Overrides destination address for token transfers (ERC-681)
// `address` parameter is used only if the contract address, encoded in the QR,
// matches the contract address of the token.
// Otherwise, the scanned string is likely malformed, and we stop the entire parsing routin
if (tokenCurrency.contractAddress.equals(address, ignoreCase = true)) {
result.address = entry.value
if (tokenCurrency.contractAddress.equals(parsed.address, ignoreCase = true)) {
result.address = addressValue
} else {
return QrResult()
}
}
Parameter.Value,
Parameter.Uint256,
-> {
// Extra convert parses scientific notation to decimal
// This is necessary to be able comparing BigDecimal values
result.amount = entry.value.parseBigDecimalOrNull()
// ERC-681: value/uint256 is in the smallest unit, needs conversion
val valueStr = parsed.params[QrSentUriParser.PARAM_VALUE]
?: parsed.params[QrSentUriParser.PARAM_UINT256]
if (valueStr != null) {
result.amount = valueStr.parseBigDecimalOrNull()
?.toPlainString()?.toBigDecimalOrNull()
?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals))
}
}
}
return result
}
override fun classify(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
return qrContentClassifierParser.parse(qrCode, userCurrencies)
}
private fun stripSchema(raw: String, currency: CryptoCurrency): String {
val qrSchemas = currency.network.toBlockchain().getShareScheme()
// The most specific (i.e. the most lengthy) prefixes always come first
qrSchemas
.sortedByDescending { it.length }
.forEach { schema ->
val stripped = raw.split(schema)
if (stripped.size > 1) return stripped.last()
}
return raw
}
private fun extractParameters(from: String): Map<Parameter, String> {
val parametersBlock = from.substringAfter(PARAM_DELIMITER)
if (parametersBlock.isBlank()) return emptyMap()
val paramList = parametersBlock.split(PARAMS_DELIMITER)
.mapNotNull { param ->
val parameterWithValue = param.split(PARAM_VALUE_DELIMITER)
if (parameterWithValue.size == 2) {
val name = Parameter.getParam(parameterWithValue.first())
val value = parameterWithValue.last()
if (name != null) {
name to value
} else {
null
}
} else {
null
}
}.associate { it }
return paramList
}
private enum class Parameter {
Amount,
Message,
Memo,
Address,
Value,
Uint256,
;
companion object {
fun getParam(name: String): Parameter? {
return Parameter.entries.firstOrNull { it.name.equals(name, ignoreCase = true) }
}
}
}
private companion object {
// See https://eips.ethereum.org/EIPS/eip-681 for details.
const val CHAIN_DELIMITER = '@' // ERC-681 [ "@" chain_id ]
const val FUNCTION_DELIMITER = '/' // ERC-681 [ "/" function_name ]
const val PARAM_DELIMITER = '?' // BIP-021, ERC-681
const val PARAMS_DELIMITER = '&'
const val PARAM_VALUE_DELIMITER = '='
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.qrscanning
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
@ -13,7 +14,8 @@ import java.math.BigDecimal
internal class DefaultQrScanningEventsRepositoryTest {
private val repository = DefaultQrScanningEventsRepository()
private val qrContentClassifier: QrContentClassifierParser = mockk()
private val repository = DefaultQrScanningEventsRepository(qrContentClassifier)
private val cryptoCurrencyId = mockk<CryptoCurrency.ID>()
private val network = mockk<Network>()

View file

@ -0,0 +1,305 @@
package com.tangem.data.qrscanning
import com.google.common.truth.Truth.assertThat
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import java.math.BigDecimal
internal class QrContentClassifierTest {
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
every { getShareSchemes(any()) } returns emptyList()
every { validateAddress(any(), any()) } returns false
}
private val classifier = QrContentClassifierParser(blockchainDataProvider)
// region WalletConnect
@Test
fun `WalletConnect URI is classified correctly`() {
val uri = "wc:a4f86d5-72ac-46ad-a1aa-9e@1-f5c0c@2"
val result = classifier.parse(uri, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(uri)
}
@Test
fun `WalletConnect URI takes priority over address matching`() {
val uri = "wc:something"
every { blockchainDataProvider.validateAddress(any(), uri) } returns true
val result = classifier.parse(uri, listOf(bitcoinCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
}
@Test
fun `dApp URL with wc uri query param is classified as WalletConnect`() {
val dAppUrl = "https://uniswap.org/app/wc?uri=wc:6ea45@2?relay-protocol=irn&symKey=f2de&expiryTimestamp=123"
val result = classifier.parse(dAppUrl, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(dAppUrl)
}
@Test
fun `HTTP URL without wc uri param is not classified as WalletConnect`() {
val url = "https://example.com/page?foo=bar"
val result = classifier.parse(url, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `HTTP URL with uri param not starting with wc is not WalletConnect`() {
val url = "https://example.com/page?uri=https://other.com"
val result = classifier.parse(url, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
// endregion
// region PaymentUri
@Test
fun `Bitcoin BIP-021 URI with amount is parsed`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5"
val result = classifier.parse(qr, listOf(bitcoinCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.currency).isEqualTo(bitcoinCoin)
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
assertThat(paymentUri.amount).isEqualTo(BigDecimal("0.5"))
assertThat(paymentUri.memo).isNull()
}
@Test
fun `Bitcoin URI without params returns address only`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
val result = classifier.parse(qr, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
assertThat(paymentUri.amount).isNull()
assertThat(paymentUri.memo).isNull()
}
@Test
fun `Bitcoin URI with message param is parsed as memo`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.0&message=test%20memo"
val result = classifier.parse(qr, listOf(bitcoinCoin))
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.0"))
assertThat(paymentUri.memo).isEqualTo("test memo")
}
@Test
fun `URI with memo parameter is parsed`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?memo=hello"
val result = classifier.parse(qr, listOf(bitcoinCoin))
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.memo).isEqualTo("hello")
}
@Test
fun `Ethereum ERC-681 URI with chain_id and function is parsed`() {
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:")
val qr = "ethereum:0x1234567890abcdef1234567890abcdef12345678@1/transfer?amount=1.5"
val result = classifier.parse(qr, listOf(ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.address).isEqualTo("0x1234567890abcdef1234567890abcdef12345678")
assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.5"))
}
@Test
fun `URI scheme not matching user currencies falls through`() {
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
val result = classifier.parse(qr, listOf(ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `Longest matching scheme is preferred`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns
listOf("bitcoin:", "bitcoin://")
val qr = "bitcoin://1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
val result = classifier.parse(qr, listOf(bitcoinCoin))
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
}
// endregion
// region PlainAddress
@Test
fun `Plain address matches single currency`() {
val address = "0x1234567890abcdef1234567890abcdef12345678"
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
val result = classifier.parse(address, listOf(ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
val plain = result as ClassifiedQrContent.PlainAddress
assertThat(plain.address).isEqualTo(address)
assertThat(plain.matchingCurrencies).containsExactly(ethereumCoin)
}
@Test
fun `Plain address matches multiple currencies`() {
val address = "0x1234567890abcdef1234567890abcdef12345678"
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
every { blockchainDataProvider.validateAddress(bscCoin.network, address) } returns true
val result = classifier.parse(address, listOf(ethereumCoin, bscCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
val plain = result as ClassifiedQrContent.PlainAddress
assertThat(plain.matchingCurrencies).hasSize(2)
}
// endregion
// region Unknown
@Test
fun `Random string returns Unknown`() {
val result = classifier.parse("hello world", listOf(bitcoinCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
assertThat((result as ClassifiedQrContent.Unknown).raw).isEqualTo("hello world")
}
@Test
fun `Empty string returns Unknown`() {
val result = classifier.parse("", listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `Empty currencies list returns Unknown`() {
val result = classifier.parse("0x1234567890abcdef1234567890abcdef12345678", emptyList())
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
// endregion
// region Edge cases
@Test
fun `Tokens are filtered out, only Coins are used`() {
val token = CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
),
network = buildNetwork("ethereum"),
name = "USDT",
symbol = "USDT",
decimals = 6,
iconUrl = null,
isCustom = false,
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
)
val result = classifier.parse("0x1234", listOf(token))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `Duplicate coins with same network are deduplicated`() {
val address = "0x1234567890abcdef1234567890abcdef12345678"
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
val result = classifier.parse(address, listOf(ethereumCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
val plain = result as ClassifiedQrContent.PlainAddress
assertThat(plain.matchingCurrencies).hasSize(1)
}
@Test
fun `Payment URI takes priority over plain address match`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
every { blockchainDataProvider.validateAddress(bitcoinCoin.network, any()) } returns true
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1"
val result = classifier.parse(qr, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
}
// endregion
// region Helpers
private val bitcoinCoin = buildCoin("bitcoin")
private val ethereumCoin = buildCoin("ethereum")
private val bscCoin = buildCoin("bsc")
private fun buildCoin(rawNetworkId: String): CryptoCurrency.Coin {
return CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
),
network = buildNetwork(rawNetworkId),
name = rawNetworkId,
symbol = rawNetworkId.take(3).uppercase(),
decimals = 8,
iconUrl = null,
isCustom = false,
)
}
private fun buildNetwork(rawNetworkId: String): Network {
return Network(
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
backendId = rawNetworkId,
name = rawNetworkId,
currencySymbol = rawNetworkId.take(3).uppercase(),
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
hasFiatFeeRate = false,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
}
// endregion
}

View file

@ -2,3 +2,8 @@ plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
dependencies {
/** Domain */
implementation(projects.domain.models)
}

View file

@ -0,0 +1,23 @@
package com.tangem.domain.qrscanning.models
import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigDecimal
sealed class ClassifiedQrContent {
data class WalletConnect(val uri: String) : ClassifiedQrContent()
data class PaymentUri(
val currency: CryptoCurrency,
val address: String,
val amount: BigDecimal?,
val memo: String?,
) : ClassifiedQrContent()
data class PlainAddress(
val address: String,
val matchingCurrencies: List<CryptoCurrency>,
) : ClassifiedQrContent()
data class Unknown(val raw: String) : ClassifiedQrContent()
}

View file

@ -1,6 +1,7 @@
package com.tangem.domain.qrscanning.repository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import com.tangem.domain.qrscanning.models.QrResult
import com.tangem.domain.qrscanning.models.RawQrResult
import com.tangem.domain.qrscanning.models.SourceType
@ -13,4 +14,6 @@ interface QrScanningEventsRepository {
fun subscribeToScanningResults(type: SourceType): Flow<RawQrResult>
fun parseQrCode(qrCode: String, cryptoCurrency: CryptoCurrency): QrResult
fun classify(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent
}

View file

@ -0,0 +1,13 @@
package com.tangem.domain.qrscanning.usecases
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
class ClassifyQrCodeUseCase(
private val repository: QrScanningEventsRepository,
) {
operator fun invoke(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
return repository.classify(qrCode, userCurrencies)
}
}

View file

@ -97,6 +97,10 @@ dependencies {
implementation(projects.domain.markets.models)
implementation(projects.domain.models)
implementation(projects.domain.networks)
implementation(projects.domain.qrScanning)
implementation(projects.domain.qrScanning.models)
implementation(projects.domain.walletConnect)
implementation(projects.domain.walletConnect.models)
implementation(projects.domain.nft)
implementation(projects.domain.nft.models)
implementation(projects.domain.hotWallet)

View file

@ -22,8 +22,16 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.*
import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.qrscanning.models.QrResultSource
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.walletconnect.WcPairService
import com.tangem.domain.walletconnect.model.WcPairRequest
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.qrscanning.usecases.ClassifyQrCodeUseCase
import com.tangem.domain.settings.*
import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase
import com.tangem.domain.wallets.usecase.*
@ -32,6 +40,7 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.router.InnerWalletRouter
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.SelectedWalletAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory
import com.tangem.feature.wallet.presentation.wallet.domain.WalletContentFetcher
@ -104,6 +113,10 @@ internal class WalletModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getWalletIconUseCase: GetWalletIconUseCase,
private val walletFeatureToggles: WalletFeatureToggles,
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
private val wcPairService: WcPairService,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
private val classifyQrCodeUseCase: ClassifyQrCodeUseCase,
val screenLifecycleProvider: ScreenLifecycleProvider,
val innerWalletRouter: InnerWalletRouter,
) : Model() {
@ -134,6 +147,7 @@ internal class WalletModel @Inject constructor(
subscribeToScreenBackgroundState()
subscribeOnPushNotificationsPermission()
subscribeTangemPayOnWalletState()
subscribeToMainScreenQrScanning()
enableNotificationsIfNeeded()
clickIntents.initialize(innerWalletRouter, modelScope)
@ -730,6 +744,65 @@ internal class WalletModel @Inject constructor(
}
}
private fun subscribeToMainScreenQrScanning() {
listenToQrScanningUseCase.listen(SourceType.MAIN_SCREEN)
.getOrElse { emptyFlow() }
.onEach { rawResult -> handleQrResult(rawResult.qrCode, rawResult.resultSource) }
.launchIn(modelScope)
}
private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) {
val userWalletId = stateHolder.getSelectedWalletId()
val currencies = multiWalletCryptoCurrenciesSupplier
.getSyncOrNull(MultiWalletCryptoCurrenciesProducer.Params(userWalletId))
?.toList()
.orEmpty()
val classified = classifyQrCodeUseCase(qrCode, currencies)
when (classified) {
is ClassifiedQrContent.WalletConnect -> {
val source = when (resultSource) {
QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD
QrResultSource.CAMERA,
QrResultSource.GALLERY,
-> WcPairRequest.Source.QR
}
wcPairService.pair(
WcPairRequest(
userWalletId = userWalletId,
uri = classified.uri,
source = source,
),
)
}
is ClassifiedQrContent.PaymentUri -> {
innerWalletRouter.openSend(
userWalletId = userWalletId,
currency = classified.currency,
address = classified.address,
amount = classified.amount?.toPlainString(),
tag = classified.memo,
)
}
is ClassifiedQrContent.PlainAddress -> {
if (classified.matchingCurrencies.size == 1) {
innerWalletRouter.openSend(
userWalletId = userWalletId,
currency = classified.matchingCurrencies.first(),
address = classified.address,
amount = null,
tag = null,
)
}
// TODO: [REDACTED_TASK_KEY] Network selection bottom sheet for multiple network matches
}
is ClassifiedQrContent.Unknown -> {
// TODO: [REDACTED_TASK_KEY] Error handling for unsupported and invalid QR codes
}
}
}
private fun enableNotificationsIfNeeded() {
modelScope.launch {
val isUserAllowToEnableNotifications = notificationsRepository.isUserAllowToSubscribeOnPushNotifications()

View file

@ -179,6 +179,24 @@ internal class DefaultWalletRouter @Inject constructor(
router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.MainScreen))
}
override fun openSend(
userWalletId: UserWalletId,
currency: CryptoCurrency,
address: String,
amount: String?,
tag: String?,
) {
router.push(
AppRoute.Send(
userWalletId = userWalletId,
currency = currency,
destinationAddress = address,
amount = amount,
tag = tag,
),
)
}
inner class OrganizeCallbacks : OrganizeTokensComponent.Callback {
override fun onDismiss() {
dialogNavigation.dismiss()

View file

@ -92,4 +92,7 @@ internal interface InnerWalletRouter {
/** Open QR scanner screen */
fun openQrScanner()
/** Open send screen with prefilled destination */
fun openSend(userWalletId: UserWalletId, currency: CryptoCurrency, address: String, amount: String?, tag: String?)
}

View file

@ -0,0 +1,23 @@
package com.tangem.feature.wallet.presentation.wallet.qr
import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigDecimal
internal sealed class ClassifiedQrContent {
data class WalletConnect(val uri: String) : ClassifiedQrContent()
data class PaymentUri(
val currency: CryptoCurrency,
val address: String,
val amount: BigDecimal?,
val memo: String?,
) : ClassifiedQrContent()
data class PlainAddress(
val address: String,
val matchingCurrencies: List<CryptoCurrency>,
) : ClassifiedQrContent()
data class Unknown(val raw: String) : ClassifiedQrContent()
}

View file

@ -0,0 +1,120 @@
package com.tangem.feature.wallet.presentation.wallet.qr
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import java.net.URLDecoder
internal class QrContentClassifier(
private val blockchainDataProvider: BlockchainDataProvider,
) {
fun classify(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
if (qrCode.startsWith(WC_PREFIX)) {
return ClassifiedQrContent.WalletConnect(qrCode)
}
if (isDAppWcUrl(qrCode)) {
return ClassifiedQrContent.WalletConnect(qrCode)
}
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)
}
if (matchingCurrencies.isNotEmpty()) {
return ClassifiedQrContent.PlainAddress(
address = qrCode,
matchingCurrencies = matchingCurrencies,
)
}
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 address = withoutScheme.takeWhile {
it != CHAIN_DELIMITER && it != FUNCTION_DELIMITER && it != PARAM_DELIMITER
}
val params = extractParameters(withoutScheme)
if (address.isBlank()) return@firstNotNullOfOrNull null
val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull()
val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let {
runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it)
}
ClassifiedQrContent.PaymentUri(
currency = coin,
address = address,
amount = amount,
memo = memo,
)
}
}
private fun isDAppWcUrl(qrCode: String): Boolean {
if (!qrCode.startsWith(HTTP_PREFIX) && !qrCode.startsWith(HTTPS_PREFIX)) return false
val uriParam = extractParameters(qrCode)[PARAM_URI] ?: return false
val decodedUri = runCatching { URLDecoder.decode(uriParam, CHARSET_UTF8) }.getOrDefault(uriParam)
return decodedUri.startsWith(WC_PREFIX)
}
private fun extractParameters(from: String): Map<String, String> {
val paramsBlock = from.substringAfter(PARAM_DELIMITER, missingDelimiterValue = "")
if (paramsBlock.isBlank()) return emptyMap()
return paramsBlock.split(PARAMS_DELIMITER)
.mapNotNull { param ->
val parts = param.split(PARAM_VALUE_DELIMITER, limit = 2)
if (parts.size == 2) parts[0].lowercase() to parts[1] else null
}
.toMap()
}
internal interface BlockchainDataProvider {
fun getShareSchemes(network: Network): List<String>
fun validateAddress(network: Network, address: String): Boolean
}
internal class DefaultBlockchainDataProvider : BlockchainDataProvider {
override fun getShareSchemes(network: Network): List<String> {
return runCatching { network.toBlockchain().getShareScheme() }.getOrDefault(emptyList())
}
override fun validateAddress(network: Network, address: String): Boolean {
return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false)
}
}
private companion object {
const val HTTP_PREFIX = "http://"
const val HTTPS_PREFIX = "https://"
const val PARAM_URI = "uri"
const val WC_PREFIX = "wc:"
const val CHAIN_DELIMITER = '@'
const val FUNCTION_DELIMITER = '/'
const val PARAM_DELIMITER = '?'
const val PARAMS_DELIMITER = '&'
const val PARAM_VALUE_DELIMITER = '='
const val PARAM_AMOUNT = "amount"
const val PARAM_MEMO = "memo"
const val PARAM_MESSAGE = "message"
const val CHARSET_UTF8 = "UTF-8"
}
}

View file

@ -0,0 +1,303 @@
package com.tangem.feature.wallet.presentation.wallet.qr
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import java.math.BigDecimal
internal class QrContentClassifierTest {
private val blockchainDataProvider = mockk<QrContentClassifier.BlockchainDataProvider> {
every { getShareSchemes(any()) } returns emptyList()
every { validateAddress(any(), any()) } returns false
}
private val classifier = QrContentClassifier(blockchainDataProvider)
// region WalletConnect
@Test
fun `WalletConnect URI is classified correctly`() {
val uri = "wc:a4f86d5-72ac-46ad-a1aa-9e@1-f5c0c@2"
val result = classifier.classify(uri, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(uri)
}
@Test
fun `WalletConnect URI takes priority over address matching`() {
val uri = "wc:something"
every { blockchainDataProvider.validateAddress(any(), uri) } returns true
val result = classifier.classify(uri, listOf(bitcoinCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
}
@Test
fun `dApp URL with wc uri query param is classified as WalletConnect`() {
val dAppUrl = "https://uniswap.org/app/wc?uri=wc:6ea45@2?relay-protocol=irn&symKey=f2de&expiryTimestamp=123"
val result = classifier.classify(dAppUrl, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(dAppUrl)
}
@Test
fun `HTTP URL without wc uri param is not classified as WalletConnect`() {
val url = "https://example.com/page?foo=bar"
val result = classifier.classify(url, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `HTTP URL with uri param not starting with wc is not WalletConnect`() {
val url = "https://example.com/page?uri=https://other.com"
val result = classifier.classify(url, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
// endregion
// region PaymentUri
@Test
fun `Bitcoin BIP-021 URI with amount is parsed`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5"
val result = classifier.classify(qr, listOf(bitcoinCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.currency).isEqualTo(bitcoinCoin)
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
assertThat(paymentUri.amount).isEqualTo(BigDecimal("0.5"))
assertThat(paymentUri.memo).isNull()
}
@Test
fun `Bitcoin URI without params returns address only`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
val result = classifier.classify(qr, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
assertThat(paymentUri.amount).isNull()
assertThat(paymentUri.memo).isNull()
}
@Test
fun `Bitcoin URI with message param is parsed as memo`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.0&message=test%20memo"
val result = classifier.classify(qr, listOf(bitcoinCoin))
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.0"))
assertThat(paymentUri.memo).isEqualTo("test memo")
}
@Test
fun `URI with memo parameter is parsed`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?memo=hello"
val result = classifier.classify(qr, listOf(bitcoinCoin))
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.memo).isEqualTo("hello")
}
@Test
fun `Ethereum ERC-681 URI with chain_id and function is parsed`() {
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:")
val qr = "ethereum:0x1234567890abcdef1234567890abcdef12345678@1/transfer?amount=1.5"
val result = classifier.classify(qr, listOf(ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.address).isEqualTo("0x1234567890abcdef1234567890abcdef12345678")
assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.5"))
}
@Test
fun `URI scheme not matching user currencies falls through`() {
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
val result = classifier.classify(qr, listOf(ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `Longest matching scheme is preferred`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns
listOf("bitcoin:", "bitcoin://")
val qr = "bitcoin://1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
val result = classifier.classify(qr, listOf(bitcoinCoin))
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
}
// endregion
// region PlainAddress
@Test
fun `Plain address matches single currency`() {
val address = "0x1234567890abcdef1234567890abcdef12345678"
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
val result = classifier.classify(address, listOf(ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
val plain = result as ClassifiedQrContent.PlainAddress
assertThat(plain.address).isEqualTo(address)
assertThat(plain.matchingCurrencies).containsExactly(ethereumCoin)
}
@Test
fun `Plain address matches multiple currencies`() {
val address = "0x1234567890abcdef1234567890abcdef12345678"
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
every { blockchainDataProvider.validateAddress(bscCoin.network, address) } returns true
val result = classifier.classify(address, listOf(ethereumCoin, bscCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
val plain = result as ClassifiedQrContent.PlainAddress
assertThat(plain.matchingCurrencies).hasSize(2)
}
// endregion
// region Unknown
@Test
fun `Random string returns Unknown`() {
val result = classifier.classify("hello world", listOf(bitcoinCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
assertThat((result as ClassifiedQrContent.Unknown).raw).isEqualTo("hello world")
}
@Test
fun `Empty string returns Unknown`() {
val result = classifier.classify("", listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `Empty currencies list returns Unknown`() {
val result = classifier.classify("0x1234567890abcdef1234567890abcdef12345678", emptyList())
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
// endregion
// region Edge cases
@Test
fun `Tokens are filtered out, only Coins are used`() {
val token = CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
),
network = buildNetwork("ethereum"),
name = "USDT",
symbol = "USDT",
decimals = 6,
iconUrl = null,
isCustom = false,
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
)
val result = classifier.classify("0x1234", listOf(token))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `Duplicate coins with same network are deduplicated`() {
val address = "0x1234567890abcdef1234567890abcdef12345678"
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
val result = classifier.classify(address, listOf(ethereumCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
val plain = result as ClassifiedQrContent.PlainAddress
assertThat(plain.matchingCurrencies).hasSize(1)
}
@Test
fun `Payment URI takes priority over plain address match`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
every { blockchainDataProvider.validateAddress(bitcoinCoin.network, any()) } returns true
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1"
val result = classifier.classify(qr, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
}
// endregion
// region Helpers
private val bitcoinCoin = buildCoin("bitcoin")
private val ethereumCoin = buildCoin("ethereum")
private val bscCoin = buildCoin("bsc")
private fun buildCoin(rawNetworkId: String): CryptoCurrency.Coin {
return CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
),
network = buildNetwork(rawNetworkId),
name = rawNetworkId,
symbol = rawNetworkId.take(3).uppercase(),
decimals = 8,
iconUrl = null,
isCustom = false,
)
}
private fun buildNetwork(rawNetworkId: String): Network {
return Network(
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
backendId = rawNetworkId,
name = rawNetworkId,
currencySymbol = rawNetworkId.take(3).uppercase(),
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
hasFiatFeeRate = false,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
}
// endregion
}