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
|
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.repository.QrScanningEventsRepository
|
||||||
import com.tangem.domain.qrscanning.usecases.ClassifyQrCodeUseCase
|
|
||||||
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
|
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
|
||||||
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||||
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
|
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
|
||||||
|
import com.tangem.domain.qrscanning.usecases.ResolveQrSendTargetsUseCase
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -35,7 +36,13 @@ internal object QrScanningDomainModule {
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideClassifyQrCodeUseCase(repository: QrScanningEventsRepository): ClassifyQrCodeUseCase {
|
fun provideResolveQrSendTargetsUseCase(
|
||||||
return ClassifyQrCodeUseCase(repository)
|
multiAccountListSupplier: MultiAccountListSupplier,
|
||||||
|
qrScanningEventsRepository: QrScanningEventsRepository,
|
||||||
|
): ResolveQrSendTargetsUseCase {
|
||||||
|
return ResolveQrSendTargetsUseCase(
|
||||||
|
multiAccountListSupplier = multiAccountListSupplier,
|
||||||
|
qrScanningEventsRepository = qrScanningEventsRepository,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package com.tangem.data.qrscanning.di
|
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.parser.QrContentClassifierParser
|
||||||
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
|
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
|
||||||
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
||||||
|
|
@ -16,10 +18,14 @@ internal object QrScanningDataModule {
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideQrScanningEventsRepository(): QrScanningEventsRepository {
|
fun provideQrScanningEventsRepository(): QrScanningEventsRepository {
|
||||||
|
val blockchainDataProvider = QrContentClassifierParser.DefaultBlockchainDataProvider()
|
||||||
return DefaultQrScanningEventsRepository(
|
return DefaultQrScanningEventsRepository(
|
||||||
qrContentClassifierParser = QrContentClassifierParser(
|
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(
|
internal class QrContentClassifierParser(
|
||||||
private val blockchainDataProvider: BlockchainDataProvider,
|
private val blockchainDataProvider: BlockchainDataProvider,
|
||||||
private val paymentUriParser: QrSentUriParser = QrSentUriParser(),
|
private val paymentUriParsers: Set<PaymentUriParser>,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun parse(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
|
fun parse(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
|
||||||
|
|
@ -23,14 +23,19 @@ internal class QrContentClassifierParser(
|
||||||
val coins = userCurrencies.filterIsInstance<CryptoCurrency.Coin>()
|
val coins = userCurrencies.filterIsInstance<CryptoCurrency.Coin>()
|
||||||
val uniqueCoins = coins.distinctBy { it.network.id }
|
val uniqueCoins = coins.distinctBy { it.network.id }
|
||||||
|
|
||||||
val paymentUri = tryParsePaymentUri(qrCode, uniqueCoins)
|
when (val paymentUriResult = tryParsePaymentUri(qrCode, uniqueCoins, userCurrencies)) {
|
||||||
if (paymentUri != null) return paymentUri
|
is PaymentUriParser.ParseResult.Success -> return paymentUriResult.content
|
||||||
|
is PaymentUriParser.ParseResult.RecognizedButNoMatch -> return ClassifiedQrContent.Unknown(qrCode)
|
||||||
val matchingCurrencies = uniqueCoins.filter { coin ->
|
is PaymentUriParser.ParseResult.NotRecognized -> Unit
|
||||||
blockchainDataProvider.validateAddress(coin.network, qrCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
return ClassifiedQrContent.PlainAddress(
|
||||||
address = qrCode,
|
address = qrCode,
|
||||||
matchingCurrencies = matchingCurrencies,
|
matchingCurrencies = matchingCurrencies,
|
||||||
|
|
@ -40,29 +45,21 @@ internal class QrContentClassifierParser(
|
||||||
return ClassifiedQrContent.Unknown(qrCode)
|
return ClassifiedQrContent.Unknown(qrCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tryParsePaymentUri(qrCode: String, coins: List<CryptoCurrency.Coin>): ClassifiedQrContent.PaymentUri? {
|
private fun tryParsePaymentUri(
|
||||||
return coins.firstNotNullOfOrNull { coin ->
|
qrCode: String,
|
||||||
val matchedScheme = blockchainDataProvider.getShareSchemes(coin.network)
|
coins: List<CryptoCurrency.Coin>,
|
||||||
.sortedByDescending { it.length }
|
allCurrencies: List<CryptoCurrency>,
|
||||||
.firstOrNull { qrCode.startsWith(it) }
|
): PaymentUriParser.ParseResult {
|
||||||
?: return@firstNotNullOfOrNull null
|
return paymentUriParsers.firstNotNullOfOrNull { parser ->
|
||||||
|
parser.parse(qrCode, coins, allCurrencies).takeUnless { it is PaymentUriParser.ParseResult.NotRecognized }
|
||||||
val withoutScheme = qrCode.removePrefix(matchedScheme)
|
} ?: PaymentUriParser.ParseResult.NotRecognized
|
||||||
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 {
|
private fun isDAppWcUrl(qrCode: String): Boolean {
|
||||||
if (!qrCode.startsWith(HTTP_PREFIX) && !qrCode.startsWith(HTTPS_PREFIX)) return false
|
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(
|
val decodedUri = runCatching { URLDecoder.decode(
|
||||||
uriParam,
|
uriParam,
|
||||||
QrSentUriParser.CHARSET_UTF8,
|
QrSentUriParser.CHARSET_UTF8,
|
||||||
|
|
@ -73,6 +70,7 @@ internal class QrContentClassifierParser(
|
||||||
internal interface BlockchainDataProvider {
|
internal interface BlockchainDataProvider {
|
||||||
fun getShareSchemes(network: Network): List<String>
|
fun getShareSchemes(network: Network): List<String>
|
||||||
fun validateAddress(network: Network, address: String): Boolean
|
fun validateAddress(network: Network, address: String): Boolean
|
||||||
|
fun getChainId(network: Network): Long?
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class DefaultBlockchainDataProvider : BlockchainDataProvider {
|
internal class DefaultBlockchainDataProvider : BlockchainDataProvider {
|
||||||
|
|
@ -83,6 +81,10 @@ internal class QrContentClassifierParser(
|
||||||
override fun validateAddress(network: Network, address: String): Boolean {
|
override fun validateAddress(network: Network, address: String): Boolean {
|
||||||
return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false)
|
return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getChainId(network: Network): Long? {
|
||||||
|
return runCatching { network.toBlockchain().getChainId()?.toLong() }.getOrNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
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
|
package com.tangem.data.qrscanning
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.tangem.data.qrscanning.parser.PaymentUriParser
|
||||||
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
|
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.network.Network
|
import com.tangem.domain.models.network.Network
|
||||||
|
|
@ -15,8 +16,15 @@ internal class QrContentClassifierTest {
|
||||||
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
|
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
|
||||||
every { getShareSchemes(any()) } returns emptyList()
|
every { getShareSchemes(any()) } returns emptyList()
|
||||||
every { validateAddress(any(), any()) } returns false
|
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
|
// region WalletConnect
|
||||||
|
|
||||||
|
|
@ -69,91 +77,37 @@ internal class QrContentClassifierTest {
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region PaymentUri
|
// region PaymentUri delegation
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Bitcoin BIP-021 URI with amount is parsed`() {
|
fun `PaymentUri is returned when parser matches`() {
|
||||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
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("ethereum:0xRecipient@1?value=1500000000000000000", listOf(ethereumCoin))
|
||||||
val result = classifier.parse(qr, listOf(bitcoinCoin, 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)
|
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
|
// endregion
|
||||||
|
|
@ -186,6 +140,20 @@ internal class QrContentClassifierTest {
|
||||||
assertThat(plain.matchingCurrencies).hasSize(2)
|
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
|
// endregion
|
||||||
|
|
||||||
// region Unknown
|
// region Unknown
|
||||||
|
|
@ -212,55 +180,15 @@ internal class QrContentClassifierTest {
|
||||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
|
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
// region Edge cases
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Tokens are filtered out, only Coins are used`() {
|
fun `Tokens alone without Coins cannot match addresses`() {
|
||||||
val token = CryptoCurrency.Token(
|
val token = buildToken("ethereum", "USDT", "0xdAC17F958D2ee523a2206206994597C13D831ec7")
|
||||||
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))
|
val result = classifier.parse("0x1234", listOf(token))
|
||||||
|
|
||||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
|
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
|
// endregion
|
||||||
|
|
||||||
// region Helpers
|
// 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 {
|
private fun buildNetwork(rawNetworkId: String): Network {
|
||||||
return Network(
|
return Network(
|
||||||
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
|
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ dependencies {
|
||||||
|
|
||||||
/** Domain */
|
/** Domain */
|
||||||
api(projects.domain.models)
|
api(projects.domain.models)
|
||||||
|
implementation(projects.domain.account)
|
||||||
implementation(projects.domain.qrScanning.models)
|
implementation(projects.domain.qrScanning.models)
|
||||||
implementation(projects.domain.tokens.models)
|
implementation(projects.domain.tokens.models)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@ sealed class ClassifiedQrContent {
|
||||||
data class WalletConnect(val uri: String) : ClassifiedQrContent()
|
data class WalletConnect(val uri: String) : ClassifiedQrContent()
|
||||||
|
|
||||||
data class PaymentUri(
|
data class PaymentUri(
|
||||||
val currency: CryptoCurrency,
|
|
||||||
val address: String,
|
val address: String,
|
||||||
val amount: BigDecimal?,
|
val amount: BigDecimal?,
|
||||||
val memo: String?,
|
val memo: String?,
|
||||||
|
val matchingCurrencies: List<CryptoCurrency>,
|
||||||
) : ClassifiedQrContent()
|
) : ClassifiedQrContent()
|
||||||
|
|
||||||
data class PlainAddress(
|
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.notifications.models)
|
||||||
implementation(projects.domain.demo.models)
|
implementation(projects.domain.demo.models)
|
||||||
implementation(projects.domain.hotWallet)
|
implementation(projects.domain.hotWallet)
|
||||||
|
implementation(projects.domain.qrScanning)
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Domain modules
|
||||||
|
implementation(projects.domain.qrScanning.models)
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
implementation(projects.common)
|
implementation(projects.common)
|
||||||
|
|
|
||||||
|
|
@ -267,29 +267,45 @@ internal class SendModel @Inject constructor(
|
||||||
private suspend fun prepareTransferTransaction(): Either<Throwable, TransactionData> {
|
private suspend fun prepareTransferTransaction(): Either<Throwable, TransactionData> {
|
||||||
val predefinedValues = predefinedValues
|
val predefinedValues = predefinedValues
|
||||||
val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value
|
val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value
|
||||||
return if (predefinedValues is PredefinedValues.Content.Deeplink) {
|
return when (predefinedValues) {
|
||||||
val predefinedAmount = predefinedValues.amount.parseBigDecimalOrNull()
|
is PredefinedValues.Content.Deeplink -> {
|
||||||
createTransferTransactionUseCase(
|
val predefinedAmount = predefinedValues.amount.parseBigDecimalOrNull()
|
||||||
amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus) ?: error("Invalid amount"),
|
createTransferTransactionUseCase(
|
||||||
memo = predefinedValues.memo,
|
amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus)
|
||||||
destination = predefinedValues.address,
|
?: error("Invalid amount"),
|
||||||
userWalletId = userWallet.walletId,
|
memo = predefinedValues.memo,
|
||||||
network = cryptoCurrency.network,
|
destination = predefinedValues.address,
|
||||||
)
|
userWalletId = userWallet.walletId,
|
||||||
} else {
|
network = cryptoCurrency.network,
|
||||||
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
|
is PredefinedValues.Content.QrCode -> {
|
||||||
val enteredMemo = destinationUM.memoTextField?.value
|
val predefinedAmount = predefinedValues.amount?.parseBigDecimalOrNull()
|
||||||
val enteredAmount = amountUM.amountTextField.cryptoAmount.value ?: error("Invalid amount")
|
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(
|
createTransferTransactionUseCase(
|
||||||
amount = enteredAmount.convertToSdkAmount(cryptoCurrencyStatus),
|
amount = enteredAmount.convertToSdkAmount(cryptoCurrencyStatus),
|
||||||
memo = enteredMemo,
|
memo = enteredMemo,
|
||||||
destination = enteredDestinationAddress,
|
destination = enteredDestinationAddress,
|
||||||
userWalletId = userWallet.walletId,
|
userWalletId = userWallet.walletId,
|
||||||
network = cryptoCurrency.network,
|
network = cryptoCurrency.network,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,6 +364,12 @@ internal class SendModel @Inject constructor(
|
||||||
memo = params.tag,
|
memo = params.tag,
|
||||||
transactionId = predefinedTxId,
|
transactionId = predefinedTxId,
|
||||||
)
|
)
|
||||||
|
} else if (predefinedAddress != null) {
|
||||||
|
PredefinedValues.Content.QrCode(
|
||||||
|
amount = predefinedAmount,
|
||||||
|
address = predefinedAddress,
|
||||||
|
memo = params.tag,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
PredefinedValues.Empty
|
PredefinedValues.Empty
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ internal class SendDestinationModel @Inject constructor(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val params = params as? DestinationBlockParams
|
val params = params as? DestinationBlockParams
|
||||||
val predefinedValues = params?.predefinedValues as? PredefinedValues.Content.Deeplink
|
val predefinedValues = params?.predefinedValues as? PredefinedValues.Content
|
||||||
if (predefinedValues?.address != null) {
|
if (predefinedValues?.address != null) {
|
||||||
_uiState.update(
|
_uiState.update(
|
||||||
SendDestinationPredefinedStateTransformer(
|
SendDestinationPredefinedStateTransformer(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import arrow.core.getOrElse
|
||||||
import com.arkivanov.decompose.router.slot.activate
|
import com.arkivanov.decompose.router.slot.activate
|
||||||
import com.arkivanov.decompose.router.slot.dismiss
|
import com.arkivanov.decompose.router.slot.dismiss
|
||||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
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.AnalyticsParam
|
||||||
import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
|
import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
|
||||||
import com.tangem.core.analytics.utils.TrackingContextProxy
|
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.QrResultSource
|
||||||
import com.tangem.domain.qrscanning.models.SourceType
|
import com.tangem.domain.qrscanning.models.SourceType
|
||||||
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
|
import com.tangem.domain.qrscanning.models.QrSendTarget
|
||||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
|
|
||||||
import com.tangem.domain.walletconnect.WcPairService
|
import com.tangem.domain.walletconnect.WcPairService
|
||||||
import com.tangem.domain.walletconnect.model.WcPairRequest
|
import com.tangem.domain.walletconnect.model.WcPairRequest
|
||||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||||
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
|
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.settings.*
|
||||||
import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase
|
import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase
|
||||||
import com.tangem.domain.wallets.usecase.*
|
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.child.wallet.model.intents.WalletClickIntents
|
||||||
import com.tangem.feature.wallet.presentation.router.InnerWalletRouter
|
import com.tangem.feature.wallet.presentation.router.InnerWalletRouter
|
||||||
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
|
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.analytics.utils.SelectedWalletAnalyticsSender
|
||||||
import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory
|
import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory
|
||||||
import com.tangem.feature.wallet.presentation.wallet.domain.WalletContentFetcher
|
import com.tangem.feature.wallet.presentation.wallet.domain.WalletContentFetcher
|
||||||
|
|
@ -115,8 +114,7 @@ internal class WalletModel @Inject constructor(
|
||||||
private val walletFeatureToggles: WalletFeatureToggles,
|
private val walletFeatureToggles: WalletFeatureToggles,
|
||||||
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
|
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
|
||||||
private val wcPairService: WcPairService,
|
private val wcPairService: WcPairService,
|
||||||
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
|
private val resolveQrSendTargetsUseCase: ResolveQrSendTargetsUseCase,
|
||||||
private val classifyQrCodeUseCase: ClassifyQrCodeUseCase,
|
|
||||||
val screenLifecycleProvider: ScreenLifecycleProvider,
|
val screenLifecycleProvider: ScreenLifecycleProvider,
|
||||||
val innerWalletRouter: InnerWalletRouter,
|
val innerWalletRouter: InnerWalletRouter,
|
||||||
) : Model() {
|
) : Model() {
|
||||||
|
|
@ -752,16 +750,8 @@ internal class WalletModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) {
|
private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) {
|
||||||
val userWalletId = stateHolder.getSelectedWalletId()
|
when (val target = resolveQrSendTargetsUseCase(qrCode)) {
|
||||||
|
is QrSendTarget.WalletConnect -> {
|
||||||
val currencies = multiWalletCryptoCurrenciesSupplier
|
|
||||||
.getSyncOrNull(MultiWalletCryptoCurrenciesProducer.Params(userWalletId))
|
|
||||||
?.toList()
|
|
||||||
.orEmpty()
|
|
||||||
val classified = classifyQrCodeUseCase(qrCode, currencies)
|
|
||||||
|
|
||||||
when (classified) {
|
|
||||||
is ClassifiedQrContent.WalletConnect -> {
|
|
||||||
val source = when (resultSource) {
|
val source = when (resultSource) {
|
||||||
QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD
|
QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD
|
||||||
QrResultSource.CAMERA,
|
QrResultSource.CAMERA,
|
||||||
|
|
@ -770,34 +760,25 @@ internal class WalletModel @Inject constructor(
|
||||||
}
|
}
|
||||||
wcPairService.pair(
|
wcPairService.pair(
|
||||||
WcPairRequest(
|
WcPairRequest(
|
||||||
userWalletId = userWalletId,
|
userWalletId = stateHolder.getSelectedWalletId(),
|
||||||
uri = classified.uri,
|
uri = target.uri,
|
||||||
source = source,
|
source = source,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is ClassifiedQrContent.PaymentUri -> {
|
is QrSendTarget.Single -> {
|
||||||
innerWalletRouter.openSend(
|
innerWalletRouter.openSend(
|
||||||
userWalletId = userWalletId,
|
userWalletId = target.userWalletId,
|
||||||
currency = classified.currency,
|
currency = target.currency,
|
||||||
address = classified.address,
|
address = target.address,
|
||||||
amount = classified.amount?.toPlainString(),
|
amount = target.amount?.parseBigDecimal(target.currency.decimals),
|
||||||
tag = classified.memo,
|
tag = target.memo,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is ClassifiedQrContent.PlainAddress -> {
|
is QrSendTarget.Multiple -> {
|
||||||
if (classified.matchingCurrencies.size == 1) {
|
// TODO: [REDACTED_TASK_KEY] Bottom sheet: Wallets (dropdown) → Accounts → Tokens
|
||||||
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 -> {
|
is QrSendTarget.Unknown -> {
|
||||||
// TODO: [REDACTED_TASK_KEY] Error handling for unsupported and invalid QR codes
|
// TODO: [REDACTED_TASK_KEY] Error handling for unsupported and invalid QR codes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue