Updated on 2026-08-14
This commit is contained in:
parent
158fd355de
commit
473aeee675
18 changed files with 1176 additions and 226 deletions
|
|
@ -1,10 +1,11 @@
|
|||
package com.tangem.tap.di.domain
|
||||
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
||||
import com.tangem.domain.qrscanning.usecases.ClassifyQrCodeUseCase
|
||||
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
|
||||
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
|
||||
import com.tangem.domain.qrscanning.usecases.ResolveQrSendTargetsUseCase
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -35,7 +36,13 @@ internal object QrScanningDomainModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideClassifyQrCodeUseCase(repository: QrScanningEventsRepository): ClassifyQrCodeUseCase {
|
||||
return ClassifyQrCodeUseCase(repository)
|
||||
fun provideResolveQrSendTargetsUseCase(
|
||||
multiAccountListSupplier: MultiAccountListSupplier,
|
||||
qrScanningEventsRepository: QrScanningEventsRepository,
|
||||
): ResolveQrSendTargetsUseCase {
|
||||
return ResolveQrSendTargetsUseCase(
|
||||
multiAccountListSupplier = multiAccountListSupplier,
|
||||
qrScanningEventsRepository = qrScanningEventsRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ dependencies {
|
|||
|
||||
/** Domain */
|
||||
api(projects.domain.models)
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.qrScanning.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ sealed class ClassifiedQrContent {
|
|||
data class WalletConnect(val uri: String) : ClassifiedQrContent()
|
||||
|
||||
data class PaymentUri(
|
||||
val currency: CryptoCurrency,
|
||||
val address: String,
|
||||
val amount: BigDecimal?,
|
||||
val memo: String?,
|
||||
val matchingCurrencies: List<CryptoCurrency>,
|
||||
) : ClassifiedQrContent()
|
||||
|
||||
data class PlainAddress(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
package com.tangem.domain.qrscanning.models
|
||||
|
||||
import com.tangem.domain.models.account.AccountId
|
||||
import com.tangem.domain.models.account.AccountName
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import java.math.BigDecimal
|
||||
|
||||
sealed class QrSendTarget {
|
||||
|
||||
/** Single match — navigate directly to Send */
|
||||
data class Single(
|
||||
val userWalletId: UserWalletId,
|
||||
val currency: CryptoCurrency,
|
||||
val address: String,
|
||||
val amount: BigDecimal?,
|
||||
val memo: String?,
|
||||
) : QrSendTarget()
|
||||
|
||||
/** Multiple matches — data for bottom sheet selection */
|
||||
data class Multiple(
|
||||
val address: String,
|
||||
val amount: BigDecimal?,
|
||||
val memo: String?,
|
||||
val walletGroups: List<WalletGroup>,
|
||||
) : QrSendTarget() {
|
||||
|
||||
data class WalletGroup(
|
||||
val userWalletId: UserWalletId,
|
||||
val walletName: String,
|
||||
val accounts: List<AccountGroup>,
|
||||
)
|
||||
|
||||
data class AccountGroup(
|
||||
val accountId: AccountId,
|
||||
val accountName: AccountName,
|
||||
val currencies: List<CryptoCurrency>,
|
||||
)
|
||||
}
|
||||
|
||||
data class WalletConnect(val uri: String) : QrSendTarget()
|
||||
|
||||
data class Unknown(val raw: String) : QrSendTarget()
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
package com.tangem.domain.qrscanning.usecases
|
||||
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
||||
|
||||
class ClassifyQrCodeUseCase(
|
||||
private val repository: QrScanningEventsRepository,
|
||||
) {
|
||||
operator fun invoke(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
|
||||
return repository.classify(qrCode, userCurrencies)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
package com.tangem.domain.qrscanning.usecases
|
||||
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountId
|
||||
import com.tangem.domain.models.account.AccountName
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
||||
import java.math.BigDecimal
|
||||
import com.tangem.domain.qrscanning.models.QrSendTarget
|
||||
|
||||
class ResolveQrSendTargetsUseCase(
|
||||
private val multiAccountListSupplier: MultiAccountListSupplier,
|
||||
private val qrScanningEventsRepository: QrScanningEventsRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(qrCode: String): QrSendTarget {
|
||||
val allAccountLists = multiAccountListSupplier.getSyncOrNull(Unit).orEmpty()
|
||||
|
||||
val currencyEntries = allAccountLists.flatMap { accountList ->
|
||||
accountList.accounts
|
||||
.filterIsInstance<Account.CryptoPortfolio>()
|
||||
.flatMap { account ->
|
||||
account.cryptoCurrencies.map { currency ->
|
||||
currency to CurrencyLocation(
|
||||
userWalletId = accountList.userWalletId,
|
||||
walletName = accountList.userWalletId.stringValue,
|
||||
accountId = account.accountId,
|
||||
accountName = account.accountName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val allCurrencies = currencyEntries.map { it.first }
|
||||
val currencyLocations = currencyEntries.groupBy(
|
||||
keySelector = { it.first.id },
|
||||
valueTransform = { it.second },
|
||||
)
|
||||
|
||||
val classified = qrScanningEventsRepository.classify(qrCode, allCurrencies)
|
||||
|
||||
return resolve(classified, currencyLocations)
|
||||
}
|
||||
|
||||
private fun resolve(
|
||||
classified: ClassifiedQrContent,
|
||||
currencyLocations: Map<CryptoCurrency.ID, List<CurrencyLocation>>,
|
||||
): QrSendTarget {
|
||||
return when (classified) {
|
||||
is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri)
|
||||
is ClassifiedQrContent.Unknown -> QrSendTarget.Unknown(classified.raw)
|
||||
is ClassifiedQrContent.PlainAddress -> resolveAddressTarget(
|
||||
address = classified.address,
|
||||
amount = null,
|
||||
memo = null,
|
||||
matchingCurrencies = classified.matchingCurrencies,
|
||||
currencyLocations = currencyLocations,
|
||||
)
|
||||
is ClassifiedQrContent.PaymentUri -> resolveAddressTarget(
|
||||
address = classified.address,
|
||||
amount = classified.amount,
|
||||
memo = classified.memo,
|
||||
matchingCurrencies = classified.matchingCurrencies,
|
||||
currencyLocations = currencyLocations,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveAddressTarget(
|
||||
address: String,
|
||||
amount: BigDecimal?,
|
||||
memo: String?,
|
||||
matchingCurrencies: List<CryptoCurrency>,
|
||||
currencyLocations: Map<CryptoCurrency.ID, List<CurrencyLocation>>,
|
||||
): QrSendTarget {
|
||||
val walletGroups = buildWalletGroups(matchingCurrencies, currencyLocations)
|
||||
|
||||
val singleGroup = walletGroups.singleOrNull()
|
||||
val singleCurrency = singleGroup?.accounts?.singleOrNull()?.currencies?.singleOrNull()
|
||||
|
||||
return if (singleGroup != null && singleCurrency != null) {
|
||||
QrSendTarget.Single(
|
||||
userWalletId = singleGroup.userWalletId,
|
||||
currency = singleCurrency,
|
||||
address = address,
|
||||
amount = amount,
|
||||
memo = memo,
|
||||
)
|
||||
} else {
|
||||
QrSendTarget.Multiple(
|
||||
address = address,
|
||||
amount = amount,
|
||||
memo = memo,
|
||||
walletGroups = walletGroups,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildWalletGroups(
|
||||
matchingCurrencies: List<CryptoCurrency>,
|
||||
currencyLocations: Map<CryptoCurrency.ID, List<CurrencyLocation>>,
|
||||
): List<QrSendTarget.Multiple.WalletGroup> {
|
||||
val walletMap = linkedMapOf<UserWalletId, WalletInfo>()
|
||||
val uniqueCurrencies = matchingCurrencies.distinctBy { it.id }
|
||||
|
||||
for (currency in uniqueCurrencies) {
|
||||
val locations = currencyLocations[currency.id] ?: continue
|
||||
for (location in locations) {
|
||||
val walletInfo = walletMap.getOrPut(location.userWalletId) {
|
||||
WalletInfo(location.walletName, linkedMapOf())
|
||||
}
|
||||
val accountInfo = walletInfo.accounts.getOrPut(location.accountId) {
|
||||
AccountInfo(location.accountName, mutableListOf())
|
||||
}
|
||||
accountInfo.currencies.add(currency)
|
||||
}
|
||||
}
|
||||
|
||||
return walletMap.map { (walletId, walletInfo) ->
|
||||
QrSendTarget.Multiple.WalletGroup(
|
||||
userWalletId = walletId,
|
||||
walletName = walletInfo.walletName,
|
||||
accounts = walletInfo.accounts.map { (accountId, accountInfo) ->
|
||||
QrSendTarget.Multiple.AccountGroup(
|
||||
accountId = accountId,
|
||||
accountName = accountInfo.accountName,
|
||||
currencies = accountInfo.currencies,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class CurrencyLocation(
|
||||
val userWalletId: UserWalletId,
|
||||
val walletName: String,
|
||||
val accountId: AccountId,
|
||||
val accountName: AccountName,
|
||||
)
|
||||
|
||||
private class WalletInfo(
|
||||
val walletName: String,
|
||||
val accounts: LinkedHashMap<AccountId, AccountInfo>,
|
||||
)
|
||||
|
||||
private class AccountInfo(
|
||||
val accountName: AccountName,
|
||||
val currencies: MutableList<CryptoCurrency>,
|
||||
)
|
||||
}
|
||||
|
|
@ -34,6 +34,11 @@ dependencies {
|
|||
implementation(projects.domain.notifications.models)
|
||||
implementation(projects.domain.demo.models)
|
||||
implementation(projects.domain.hotWallet)
|
||||
implementation(projects.domain.qrScanning)
|
||||
// endregion
|
||||
|
||||
// region Domain modules
|
||||
implementation(projects.domain.qrScanning.models)
|
||||
// endregion
|
||||
|
||||
implementation(projects.common)
|
||||
|
|
|
|||
|
|
@ -267,29 +267,45 @@ internal class SendModel @Inject constructor(
|
|||
private suspend fun prepareTransferTransaction(): Either<Throwable, TransactionData> {
|
||||
val predefinedValues = predefinedValues
|
||||
val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value
|
||||
return if (predefinedValues is PredefinedValues.Content.Deeplink) {
|
||||
val predefinedAmount = predefinedValues.amount.parseBigDecimalOrNull()
|
||||
createTransferTransactionUseCase(
|
||||
amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus) ?: error("Invalid amount"),
|
||||
memo = predefinedValues.memo,
|
||||
destination = predefinedValues.address,
|
||||
userWalletId = userWallet.walletId,
|
||||
network = cryptoCurrency.network,
|
||||
)
|
||||
} else {
|
||||
val destinationUM = uiState.value.destinationUM as? DestinationUM.Content ?: error("Invalid destination")
|
||||
val amountUM = uiState.value.amountUM as? AmountState.Data ?: error("Invalid amount")
|
||||
val enteredDestinationAddress = destinationUM.addressTextField.actualAddress
|
||||
val enteredMemo = destinationUM.memoTextField?.value
|
||||
val enteredAmount = amountUM.amountTextField.cryptoAmount.value ?: error("Invalid amount")
|
||||
return when (predefinedValues) {
|
||||
is PredefinedValues.Content.Deeplink -> {
|
||||
val predefinedAmount = predefinedValues.amount.parseBigDecimalOrNull()
|
||||
createTransferTransactionUseCase(
|
||||
amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus)
|
||||
?: error("Invalid amount"),
|
||||
memo = predefinedValues.memo,
|
||||
destination = predefinedValues.address,
|
||||
userWalletId = userWallet.walletId,
|
||||
network = cryptoCurrency.network,
|
||||
)
|
||||
}
|
||||
is PredefinedValues.Content.QrCode -> {
|
||||
val predefinedAmount = predefinedValues.amount?.parseBigDecimalOrNull()
|
||||
createTransferTransactionUseCase(
|
||||
amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus)
|
||||
?: error("Invalid amount"),
|
||||
memo = predefinedValues.memo,
|
||||
destination = predefinedValues.address,
|
||||
userWalletId = userWallet.walletId,
|
||||
network = cryptoCurrency.network,
|
||||
)
|
||||
}
|
||||
PredefinedValues.Empty -> {
|
||||
val destinationUM = uiState.value.destinationUM as? DestinationUM.Content
|
||||
?: error("Invalid destination")
|
||||
val amountUM = uiState.value.amountUM as? AmountState.Data ?: error("Invalid amount")
|
||||
val enteredDestinationAddress = destinationUM.addressTextField.actualAddress
|
||||
val enteredMemo = destinationUM.memoTextField?.value
|
||||
val enteredAmount = amountUM.amountTextField.cryptoAmount.value ?: error("Invalid amount")
|
||||
|
||||
createTransferTransactionUseCase(
|
||||
amount = enteredAmount.convertToSdkAmount(cryptoCurrencyStatus),
|
||||
memo = enteredMemo,
|
||||
destination = enteredDestinationAddress,
|
||||
userWalletId = userWallet.walletId,
|
||||
network = cryptoCurrency.network,
|
||||
)
|
||||
createTransferTransactionUseCase(
|
||||
amount = enteredAmount.convertToSdkAmount(cryptoCurrencyStatus),
|
||||
memo = enteredMemo,
|
||||
destination = enteredDestinationAddress,
|
||||
userWalletId = userWallet.walletId,
|
||||
network = cryptoCurrency.network,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -348,6 +364,12 @@ internal class SendModel @Inject constructor(
|
|||
memo = params.tag,
|
||||
transactionId = predefinedTxId,
|
||||
)
|
||||
} else if (predefinedAddress != null) {
|
||||
PredefinedValues.Content.QrCode(
|
||||
amount = predefinedAmount,
|
||||
address = predefinedAddress,
|
||||
memo = params.tag,
|
||||
)
|
||||
} else {
|
||||
PredefinedValues.Empty
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ internal class SendDestinationModel @Inject constructor(
|
|||
),
|
||||
)
|
||||
val params = params as? DestinationBlockParams
|
||||
val predefinedValues = params?.predefinedValues as? PredefinedValues.Content.Deeplink
|
||||
val predefinedValues = params?.predefinedValues as? PredefinedValues.Content
|
||||
if (predefinedValues?.address != null) {
|
||||
_uiState.update(
|
||||
SendDestinationPredefinedStateTransformer(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import arrow.core.getOrElse
|
|||
import com.arkivanov.decompose.router.slot.activate
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.ui.utils.parseBigDecimal
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
|
||||
import com.tangem.core.analytics.utils.TrackingContextProxy
|
||||
|
|
@ -25,13 +26,12 @@ import com.tangem.domain.notifications.repository.NotificationsRepository
|
|||
import com.tangem.domain.qrscanning.models.QrResultSource
|
||||
import com.tangem.domain.qrscanning.models.SourceType
|
||||
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
|
||||
import com.tangem.domain.qrscanning.models.QrSendTarget
|
||||
import com.tangem.domain.walletconnect.WcPairService
|
||||
import com.tangem.domain.walletconnect.model.WcPairRequest
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
|
||||
import com.tangem.domain.qrscanning.usecases.ClassifyQrCodeUseCase
|
||||
import com.tangem.domain.qrscanning.usecases.ResolveQrSendTargetsUseCase
|
||||
import com.tangem.domain.settings.*
|
||||
import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase
|
||||
import com.tangem.domain.wallets.usecase.*
|
||||
|
|
@ -40,7 +40,6 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase
|
|||
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
|
||||
import com.tangem.feature.wallet.presentation.router.InnerWalletRouter
|
||||
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.SelectedWalletAnalyticsSender
|
||||
import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory
|
||||
import com.tangem.feature.wallet.presentation.wallet.domain.WalletContentFetcher
|
||||
|
|
@ -115,8 +114,7 @@ internal class WalletModel @Inject constructor(
|
|||
private val walletFeatureToggles: WalletFeatureToggles,
|
||||
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
|
||||
private val wcPairService: WcPairService,
|
||||
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
|
||||
private val classifyQrCodeUseCase: ClassifyQrCodeUseCase,
|
||||
private val resolveQrSendTargetsUseCase: ResolveQrSendTargetsUseCase,
|
||||
val screenLifecycleProvider: ScreenLifecycleProvider,
|
||||
val innerWalletRouter: InnerWalletRouter,
|
||||
) : Model() {
|
||||
|
|
@ -752,16 +750,8 @@ internal class WalletModel @Inject constructor(
|
|||
}
|
||||
|
||||
private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) {
|
||||
val userWalletId = stateHolder.getSelectedWalletId()
|
||||
|
||||
val currencies = multiWalletCryptoCurrenciesSupplier
|
||||
.getSyncOrNull(MultiWalletCryptoCurrenciesProducer.Params(userWalletId))
|
||||
?.toList()
|
||||
.orEmpty()
|
||||
val classified = classifyQrCodeUseCase(qrCode, currencies)
|
||||
|
||||
when (classified) {
|
||||
is ClassifiedQrContent.WalletConnect -> {
|
||||
when (val target = resolveQrSendTargetsUseCase(qrCode)) {
|
||||
is QrSendTarget.WalletConnect -> {
|
||||
val source = when (resultSource) {
|
||||
QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD
|
||||
QrResultSource.CAMERA,
|
||||
|
|
@ -770,34 +760,25 @@ internal class WalletModel @Inject constructor(
|
|||
}
|
||||
wcPairService.pair(
|
||||
WcPairRequest(
|
||||
userWalletId = userWalletId,
|
||||
uri = classified.uri,
|
||||
userWalletId = stateHolder.getSelectedWalletId(),
|
||||
uri = target.uri,
|
||||
source = source,
|
||||
),
|
||||
)
|
||||
}
|
||||
is ClassifiedQrContent.PaymentUri -> {
|
||||
is QrSendTarget.Single -> {
|
||||
innerWalletRouter.openSend(
|
||||
userWalletId = userWalletId,
|
||||
currency = classified.currency,
|
||||
address = classified.address,
|
||||
amount = classified.amount?.toPlainString(),
|
||||
tag = classified.memo,
|
||||
userWalletId = target.userWalletId,
|
||||
currency = target.currency,
|
||||
address = target.address,
|
||||
amount = target.amount?.parseBigDecimal(target.currency.decimals),
|
||||
tag = target.memo,
|
||||
)
|
||||
}
|
||||
is ClassifiedQrContent.PlainAddress -> {
|
||||
if (classified.matchingCurrencies.size == 1) {
|
||||
innerWalletRouter.openSend(
|
||||
userWalletId = userWalletId,
|
||||
currency = classified.matchingCurrencies.first(),
|
||||
address = classified.address,
|
||||
amount = null,
|
||||
tag = null,
|
||||
)
|
||||
}
|
||||
// TODO: [REDACTED_TASK_KEY] Network selection bottom sheet for multiple network matches
|
||||
is QrSendTarget.Multiple -> {
|
||||
// TODO: [REDACTED_TASK_KEY] Bottom sheet: Wallets (dropdown) → Accounts → Tokens
|
||||
}
|
||||
is ClassifiedQrContent.Unknown -> {
|
||||
is QrSendTarget.Unknown -> {
|
||||
// TODO: [REDACTED_TASK_KEY] Error handling for unsupported and invalid QR codes
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue