diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt index 62ff974e39..b824e19a4e 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt @@ -3,6 +3,8 @@ 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.SolanaPaymentUriParser +import com.tangem.data.qrscanning.parser.TronPaymentUriParser import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository import dagger.Module @@ -24,7 +26,9 @@ internal object QrScanningDataModule { blockchainDataProvider = blockchainDataProvider, paymentUriParsers = setOf( Eip681PaymentUriParser(blockchainDataProvider), - Bip321PaymentUriParser(blockchainDataProvider), + TronPaymentUriParser(), + SolanaPaymentUriParser(), + Bip321PaymentUriParser(), ), ), ) diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt index 2b8dc3085f..ba281b2a7f 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt @@ -1,10 +1,12 @@ package com.tangem.data.qrscanning.parser +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.qrscanning.models.ClassifiedQrContent internal class Bip321PaymentUriParser( - private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider, + private val helper: PaymentUriResolveHelper = PaymentUriResolveHelper(), ) : PaymentUriParser { override fun parse( @@ -12,22 +14,24 @@ internal class Bip321PaymentUriParser( coins: List, allCurrencies: List, ): PaymentUriParser.ParseResult { - val schemeAndRest = extractSchemeAndRest(qrCode, coins) + val (scheme, blockchains) = SCHEME_TO_BLOCKCHAINS.entries + .firstOrNull { (scheme, _) -> qrCode.startsWith(scheme, ignoreCase = true) } ?: return PaymentUriParser.ParseResult.NotRecognized - val (matchingCoins, withoutScheme) = schemeAndRest - val parsed = QrSentUriParser().parse(withoutScheme) + val parsed = helper.parseUri(qrCode, scheme) ?: return PaymentUriParser.ParseResult.RecognizedError( ClassifiedQrContent.Error.Unrecognized(qrCode), ) + val matchingCoins = coins.filter { it.network.toBlockchain() in blockchains } val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() val matchingCurrencies = allCurrencies.filter { it.network.id in matchingNetworkIds } + if (matchingCurrencies.isEmpty()) { return PaymentUriParser.ParseResult.RecognizedError( ClassifiedQrContent.Error.UnsupportedNetwork( raw = qrCode, - blockchain = matchingCoins.firstOrNull()?.network?.name, + blockchain = blockchains.first().fullName, ), ) } @@ -42,22 +46,19 @@ internal class Bip321PaymentUriParser( ) } - private fun extractSchemeAndRest( - qrCode: String, - coins: List, - ): Pair, 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 + private companion object { + val BLOCKCHAINS = listOf( + Blockchain.Bitcoin, + Blockchain.BitcoinTestnet, + Blockchain.Litecoin, + Blockchain.Binance, + Blockchain.BinanceTestnet, + Blockchain.Dogecoin, + Blockchain.XRP, + ) + val SCHEME_TO_BLOCKCHAINS: Map> = BLOCKCHAINS + .flatMap { blockchain -> blockchain.getShareScheme().map { scheme -> scheme to blockchain } } + .groupBy({ it.first }, { it.second }) + .mapValues { it.value.toSet() } } } \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt index 2d109a40c8..1a0fa0caac 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt @@ -1,5 +1,6 @@ package com.tangem.data.qrscanning.parser +import com.tangem.blockchain.common.Blockchain import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.qrscanning.models.ClassifiedQrContent import java.math.BigDecimal @@ -14,11 +15,10 @@ internal class Eip681PaymentUriParser( coins: List, allCurrencies: List, ): PaymentUriParser.ParseResult { - if (!qrCode.startsWith(SCHEME)) { - return PaymentUriParser.ParseResult.NotRecognized - } + val scheme = SCHEMES.find { qrCode.startsWith(it, ignoreCase = true) } + ?: return PaymentUriParser.ParseResult.NotRecognized - val withoutScheme = qrCode.removePrefix(SCHEME) + val withoutScheme = qrCode.removePrefix(scheme) val parsed = parseEip681(withoutScheme) ?: return PaymentUriParser.ParseResult.NotRecognized val matchingCoins = findMatchingCoins(parsed.chainId, coins) @@ -114,7 +114,7 @@ internal class Eip681PaymentUriParser( private fun findMatchingCoins(chainId: Long?, coins: List): List { if (chainId == null) { return coins.filter { coin -> - blockchainDataProvider.getShareSchemes(coin.network).any { it.startsWith(SCHEME) } + blockchainDataProvider.getChainId(coin.network) != null } } return coins.filter { coin -> @@ -164,7 +164,7 @@ internal class Eip681PaymentUriParser( private companion object { // ethereum:
[@][/][?] val URI_REGEX = Regex("""^([^@/?]+)(?:@(\d+))?(?:/([^?]+))?(?:\?(.+))?$""") - const val SCHEME = "ethereum:" + val SCHEMES = Blockchain.Ethereum.getShareScheme() const val FUNCTION_TRANSFER = "transfer" const val PARAM_VALUE = "value" const val PARAM_ADDRESS = "address" diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriResolveHelper.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriResolveHelper.kt new file mode 100644 index 0000000000..c28f09b726 --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriResolveHelper.kt @@ -0,0 +1,84 @@ +package com.tangem.data.qrscanning.parser + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import java.math.BigDecimal + +internal class PaymentUriResolveHelper { + + fun parseUri(qrCode: String, scheme: String): QrSentUriParser.Result? { + val withoutScheme = qrCode.removeRange(0, scheme.length) + return QrSentUriParser().parse(withoutScheme) + } + + fun resolveTokenTransfer( + context: ResolveContext, + contractAddress: String, + interpretAmount: (BigDecimal, Int) -> BigDecimal = { raw, _ -> raw }, + ): PaymentUriParser.ParseResult { + val matchingTokens = context.allCurrencies.filterIsInstance() + .filter { token -> + token.network.id in context.matchingNetworkIds && + token.contractAddress.equals(contractAddress, ignoreCase = true) + } + + if (matchingTokens.isEmpty()) { + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.UnsupportedNetwork( + raw = context.qrCode, + blockchain = context.blockchainName, + ), + ) + } + + val amount = context.parsed.amount?.let { + interpretAmount(it, matchingTokens.first().decimals) + } + + return PaymentUriParser.ParseResult.Success( + ClassifiedQrContent.PaymentUri( + address = context.parsed.address, + amount = amount, + memo = context.parsed.memo, + matchingCurrencies = matchingTokens, + ), + ) + } + + fun resolveNativeOrAll( + context: ResolveContext, + interpretAmount: (BigDecimal, Int) -> BigDecimal = { raw, _ -> raw }, + ): PaymentUriParser.ParseResult { + val decimals = context.matchingCoins.firstOrNull()?.decimals + val amount = if (decimals != null) { + context.parsed.amount?.let { interpretAmount(it, decimals) } + } else { + context.parsed.amount + } + + val matchingCurrencies = if (context.parsed.amount != null) { + context.matchingCoins + } else { + context.allCurrencies.filter { it.network.id in context.matchingNetworkIds } + } + + return PaymentUriParser.ParseResult.Success( + ClassifiedQrContent.PaymentUri( + address = context.parsed.address, + amount = amount, + memo = context.parsed.memo, + matchingCurrencies = matchingCurrencies, + ), + ) + } + + data class ResolveContext( + val parsed: QrSentUriParser.Result, + val matchingCoins: List, + val matchingNetworkIds: Set, + val allCurrencies: List, + val blockchainName: String, + val qrCode: String, + ) +} \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt index dc0ef5a3db..3f8817247d 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt @@ -77,7 +77,6 @@ internal class QrContentClassifierParser( } internal interface BlockchainDataProvider { - fun getShareSchemes(network: Network): List fun validateAddress(network: Network, address: String): Boolean fun getChainId(network: Network): Long? fun findSupportedBlockchainName(address: String): String? @@ -85,10 +84,6 @@ internal class QrContentClassifierParser( } internal class DefaultBlockchainDataProvider : BlockchainDataProvider { - override fun getShareSchemes(network: Network): List { - return runCatching { network.toBlockchain().getShareScheme() }.getOrDefault(emptyList()) - } - override fun validateAddress(network: Network, address: String): Boolean { return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false) } diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt index ea80fb564d..af64a3c485 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt @@ -20,7 +20,7 @@ internal class QrSentUriParser { val params = extractParameters(withoutScheme) val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull() - val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let { + val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE] ?: params[PARAM_DT])?.let { runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it) } @@ -53,6 +53,7 @@ internal class QrSentUriParser { const val PARAM_AMOUNT = "amount" const val PARAM_MEMO = "memo" const val PARAM_MESSAGE = "message" + const val PARAM_DT = "dt" const val PARAM_ADDRESS = "address" const val PARAM_VALUE = "value" const val PARAM_UINT256 = "uint256" diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt new file mode 100644 index 0000000000..5c00d4eab8 --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt @@ -0,0 +1,55 @@ +package com.tangem.data.qrscanning.parser + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent + +internal class SolanaPaymentUriParser( + private val helper: PaymentUriResolveHelper = PaymentUriResolveHelper(), +) : PaymentUriParser { + + override fun parse( + qrCode: String, + coins: List, + allCurrencies: List, + ): PaymentUriParser.ParseResult { + val scheme = SCHEMES.find { qrCode.startsWith(it, ignoreCase = true) } + ?: return PaymentUriParser.ParseResult.NotRecognized + + val parsed = helper.parseUri(qrCode, scheme) + ?: return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.Unrecognized(qrCode), + ) + + val matchingCoins = coins.filter { it.network.toBlockchain() == BLOCKCHAIN } + if (matchingCoins.isEmpty()) { + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.UnsupportedNetwork(raw = qrCode, blockchain = BLOCKCHAIN.fullName), + ) + } + + val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() + val context = PaymentUriResolveHelper.ResolveContext( + parsed = parsed, + matchingCoins = matchingCoins, + matchingNetworkIds = matchingNetworkIds, + allCurrencies = allCurrencies, + blockchainName = BLOCKCHAIN.fullName, + qrCode = qrCode, + ) + + val splTokenMint = parsed.params[PARAM_SPL_TOKEN] + return if (splTokenMint != null) { + helper.resolveTokenTransfer(context, splTokenMint) + } else { + helper.resolveNativeOrAll(context) + } + } + + private companion object { + val BLOCKCHAIN = Blockchain.Solana + val SCHEMES = BLOCKCHAIN.getShareScheme() + const val PARAM_SPL_TOKEN = "spl-token" + } +} \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt new file mode 100644 index 0000000000..61627b9738 --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt @@ -0,0 +1,73 @@ +package com.tangem.data.qrscanning.parser + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import java.math.BigDecimal +import java.math.MathContext + +internal class TronPaymentUriParser( + private val helper: PaymentUriResolveHelper = PaymentUriResolveHelper(), +) : PaymentUriParser { + + override fun parse( + qrCode: String, + coins: List, + allCurrencies: List, + ): PaymentUriParser.ParseResult { + val scheme = SCHEMES.find { qrCode.startsWith(it, ignoreCase = true) } + ?: return PaymentUriParser.ParseResult.NotRecognized + + val parsed = helper.parseUri(qrCode, scheme) + ?: return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.Unrecognized(qrCode), + ) + + val matchingCoins = coins.filter { it.network.toBlockchain() == BLOCKCHAIN } + if (matchingCoins.isEmpty()) { + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.UnsupportedNetwork(raw = qrCode, blockchain = BLOCKCHAIN.fullName), + ) + } + + val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() + val context = PaymentUriResolveHelper.ResolveContext( + parsed = parsed, + matchingCoins = matchingCoins, + matchingNetworkIds = matchingNetworkIds, + allCurrencies = allCurrencies, + blockchainName = BLOCKCHAIN.fullName, + qrCode = qrCode, + ) + + val tokenContractAddress = parsed.params[PARAM_TOKEN] + return if (tokenContractAddress != null) { + helper.resolveTokenTransfer(context, tokenContractAddress, ::interpretAmount) + } else { + helper.resolveNativeOrAll(context, ::interpretAmount) + } + } + + /** + * Tron amount interpretation: + * - If the number contains a decimal point — use as-is + * - If no decimal point and <= [AMOUNT_THRESHOLD] — treat as a normal number (e.g. 100 = 100 TRX) + * - If no decimal point and > [AMOUNT_THRESHOLD] — treat as smallest unit, shift decimal left by [decimals] + */ + private fun interpretAmount(raw: BigDecimal, decimals: Int): BigDecimal { + val hasDecimalPoint = raw.scale() > 0 + return if (hasDecimalPoint || raw <= AMOUNT_THRESHOLD) { + raw + } else { + raw.divide(BigDecimal.TEN.pow(decimals), MathContext.DECIMAL128) + } + } + + private companion object { + val BLOCKCHAIN = Blockchain.Tron + val SCHEMES = BLOCKCHAIN.getShareScheme() + val AMOUNT_THRESHOLD = BigDecimal(100_000) + const val PARAM_TOKEN = "token" + } +} \ No newline at end of file diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt index 63a91fe88e..723ea0ef8c 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt @@ -3,28 +3,20 @@ 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 { - every { getShareSchemes(any()) } returns emptyList() - } - private val parser = Bip321PaymentUriParser(blockchainDataProvider) + private val parser = Bip321PaymentUriParser() // 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), @@ -40,8 +32,6 @@ internal class Bip321PaymentUriParserTest { @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), @@ -56,8 +46,6 @@ internal class Bip321PaymentUriParserTest { @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), @@ -72,8 +60,6 @@ internal class Bip321PaymentUriParserTest { @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), @@ -90,8 +76,6 @@ internal class Bip321PaymentUriParserTest { @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), @@ -105,11 +89,21 @@ internal class Bip321PaymentUriParserTest { } @Test - fun `no matching scheme returns NotRecognized`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") - + fun `dogecoin URI matches dogecoin coin`() { val result = parser.parse( - qrCode = "dogecoin:DAddress?amount=100", + qrCode = "doge:DAddress?amount=100", + coins = listOf(dogecoinCoin), + allCurrencies = listOf(dogecoinCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("DAddress") + } + + @Test + fun `no matching scheme returns NotRecognized`() { + val result = parser.parse( + qrCode = "solana:SomeAddress?amount=100", coins = listOf(bitcoinCoin), allCurrencies = listOf(bitcoinCoin), ) @@ -118,22 +112,18 @@ internal class Bip321PaymentUriParserTest { } @Test - fun `ethereum scheme matches as Success`() { - every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:") - + fun `ethereum scheme returns NotRecognized`() { val result = parser.parse( qrCode = "ethereum:0xRecipient?value=1000", - coins = listOf(ethereumCoin), - allCurrencies = listOf(ethereumCoin), - ).asSuccess() + coins = listOf(bitcoinCoin), + allCurrencies = listOf(bitcoinCoin), + ) - assertThat(result).isNotNull() + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) } @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), @@ -146,13 +136,26 @@ internal class Bip321PaymentUriParserTest { // endregion + // region Unsupported network + + @Test + fun `bitcoin URI with no matching coin returns UnsupportedNetwork`() { + val result = parser.parse( + qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5", + coins = listOf(litecoinCoin), + allCurrencies = listOf(litecoinCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + } + + // 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 btcToken = buildToken("BTC", "RUNE", "contractAddr") val result = parser.parse( qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.01", @@ -192,8 +195,6 @@ internal class Bip321PaymentUriParserTest { @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), @@ -212,20 +213,25 @@ internal class Bip321PaymentUriParserTest { 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 val bitcoinCoin = buildCoin("BTC", "Bitcoin", "BTC", decimals = 8) + private val litecoinCoin = buildCoin("LTC", "Litecoin", "LTC", decimals = 8) + private val dogecoinCoin = buildCoin("DOGE", "Dogecoin", "DOGE", decimals = 8) - private fun buildCoin(rawNetworkId: String, decimals: Int): CryptoCurrency.Coin { + private fun buildCoin( + rawNetworkId: String, + name: String, + symbol: 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(), + network = buildNetwork(rawNetworkId, name, symbol), + name = name, + symbol = symbol, decimals = decimals, iconUrl = null, isCustom = false, @@ -239,7 +245,7 @@ internal class Bip321PaymentUriParserTest { body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress), ), - network = buildNetwork(rawNetworkId), + network = buildNetwork(rawNetworkId, rawNetworkId, symbol), name = symbol, symbol = symbol, decimals = 6, @@ -249,12 +255,12 @@ internal class Bip321PaymentUriParserTest { ) } - private fun buildNetwork(rawNetworkId: String): Network { + private fun buildNetwork(rawNetworkId: String, name: String, symbol: String): Network { return Network( id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), backendId = rawNetworkId, - name = rawNetworkId, - currencySymbol = rawNetworkId.take(3).uppercase(), + name = name, + currencySymbol = symbol, derivationPath = Network.DerivationPath.None, isTestnet = false, standardType = Network.StandardType.Unspecified("UNSPECIFIED"), diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt index 6fca241974..f43f5e513e 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt @@ -15,7 +15,6 @@ import java.math.BigDecimal internal class Eip681PaymentUriParserTest { private val blockchainDataProvider = mockk { - every { getShareSchemes(any()) } returns emptyList() every { getChainId(any()) } returns null every { getBlockchainNameByChainId(any()) } returns null } @@ -56,8 +55,8 @@ internal class Eip681PaymentUriParserTest { } @Test - fun `native transfer without chain_id falls back to scheme matching`() { - every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:") + fun `native transfer without chain_id falls back to chainId presence check`() { + every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L val result = parser.parse( qrCode = "ethereum:0xRecipient?value=1000000000000000000", diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt index 18f3572e4c..af88722a8d 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt @@ -14,7 +14,6 @@ import java.math.BigDecimal internal class QrContentClassifierTest { private val blockchainDataProvider = mockk { - every { getShareSchemes(any()) } returns emptyList() every { validateAddress(any(), any()) } returns false every { getChainId(any()) } returns null every { findSupportedBlockchainName(any()) } returns null diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt new file mode 100644 index 0000000000..5f2935b741 --- /dev/null +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt @@ -0,0 +1,182 @@ +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.SolanaPaymentUriParser +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import org.junit.Test +import java.math.BigDecimal + +internal class SolanaPaymentUriParserTest { + + private val parser = SolanaPaymentUriParser() + + // region Scheme matching + + @Test + fun `solana URI recognized`() { + val result = parser.parse( + qrCode = "solana:SolAddress", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("SolAddress") + } + + @Test + fun `non-solana URI returns NotRecognized`() { + val result = parser.parse( + qrCode = "bitcoin:1Address", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + @Test + fun `case insensitive scheme`() { + val result = parser.parse( + qrCode = "Solana:SolAddress", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin), + ).asSuccess() + + assertThat(result).isNotNull() + } + + // endregion + + // region Native transfer + + @Test + fun `native transfer with amount`() { + val result = parser.parse( + qrCode = "solana:SolAddress?amount=2.5", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin, usdcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.amount!!.compareTo(BigDecimal("2.5"))).isEqualTo(0) + assertThat(result.matchingCurrencies).containsExactly(solanaCoin) + } + + @Test + fun `no amount returns all currencies on network`() { + val result = parser.parse( + qrCode = "solana:SolAddress", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin, usdcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.amount).isNull() + assertThat(result.matchingCurrencies).containsExactly(solanaCoin, usdcToken) + } + + // endregion + + // region SPL token transfer + + @Test + fun `spl-token param resolves to matching token`() { + val result = parser.parse( + qrCode = "solana:SolAddress?spl-token=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&amount=10", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin, usdcToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("SolAddress") + assertThat(result.amount!!.compareTo(BigDecimal("10"))).isEqualTo(0) + assertThat(result.matchingCurrencies).containsExactly(usdcToken) + } + + @Test + fun `spl-token not found returns UnsupportedNetwork`() { + val result = parser.parse( + qrCode = "solana:SolAddress?spl-token=UnknownMint", + coins = listOf(solanaCoin), + allCurrencies = listOf(solanaCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + } + + // endregion + + // region Unsupported network + + @Test + fun `no matching solana coin returns UnsupportedNetwork`() { + val result = parser.parse( + qrCode = "solana:SolAddress", + coins = emptyList(), + allCurrencies = emptyList(), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + } + + // endregion + + // region Helpers + + private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? { + return (this as? PaymentUriParser.ParseResult.Success)?.content + } + + private val solanaNetwork = buildNetwork("SOLANA", "Solana", "SOL") + + private val solanaCoin = CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("SOLANA"), + suffix = CryptoCurrency.ID.Suffix.RawID("SOLANA"), + ), + network = solanaNetwork, + name = "Solana", + symbol = "SOL", + decimals = 9, + iconUrl = null, + isCustom = false, + ) + + private val usdcToken = CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("SOLANA"), + suffix = CryptoCurrency.ID.Suffix.RawID("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), + ), + network = solanaNetwork, + name = "USD Coin", + symbol = "USDC", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ) + + private fun buildNetwork(rawNetworkId: String, name: String, symbol: String): Network { + return Network( + id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), + backendId = rawNetworkId, + name = name, + currencySymbol = symbol, + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + // endregion +} \ No newline at end of file diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt new file mode 100644 index 0000000000..63529bdd52 --- /dev/null +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt @@ -0,0 +1,222 @@ +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.TronPaymentUriParser +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import org.junit.Test +import java.math.BigDecimal + +internal class TronPaymentUriParserTest { + + private val parser = TronPaymentUriParser() + + // region Scheme matching + + @Test + fun `tron URI recognized`() { + val result = parser.parse( + qrCode = "tron:TAddress", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("TAddress") + } + + @Test + fun `non-tron URI returns NotRecognized`() { + val result = parser.parse( + qrCode = "bitcoin:1Address", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java) + } + + @Test + fun `case insensitive scheme`() { + val result = parser.parse( + qrCode = "Tron:TAddress", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ).asSuccess() + + assertThat(result).isNotNull() + } + + // endregion + + // region Native transfer + + @Test + fun `native transfer with amount`() { + val result = parser.parse( + qrCode = "tron:TAddress?amount=100", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin, usdtToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.amount!!.compareTo(BigDecimal("100"))).isEqualTo(0) + assertThat(result.matchingCurrencies).containsExactly(tronCoin) + } + + @Test + fun `no amount returns all currencies on network`() { + val result = parser.parse( + qrCode = "tron:TAddress", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin, usdtToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.amount).isNull() + assertThat(result.matchingCurrencies).containsExactly(tronCoin, usdtToken) + } + + // endregion + + // region Token transfer + + @Test + fun `token transfer with token param`() { + val result = parser.parse( + qrCode = "tron:TAddress?token=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t&amount=50", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin, usdtToken), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("TAddress") + assertThat(result.matchingCurrencies).containsExactly(usdtToken) + } + + @Test + fun `token not found returns UnsupportedNetwork`() { + val result = parser.parse( + qrCode = "tron:TAddress?token=TUnknownContract", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + } + + // endregion + + // region Amount interpretation + + @Test + fun `amount with decimal point used as-is`() { + val result = parser.parse( + qrCode = "tron:TAddress?amount=1.5", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.amount!!.compareTo(BigDecimal("1.5"))).isEqualTo(0) + } + + @Test + fun `integer amount less or equal 100000 treated as normal`() { + val result = parser.parse( + qrCode = "tron:TAddress?amount=100000", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.amount!!.compareTo(BigDecimal("100000"))).isEqualTo(0) + } + + @Test + fun `integer amount greater than 100000 treated as smallest unit`() { + // 1_000_000 with 6 decimals (TRX) = 1.0 + val result = parser.parse( + qrCode = "tron:TAddress?amount=1000000", + coins = listOf(tronCoin), + allCurrencies = listOf(tronCoin), + ).asSuccess() + + assertThat(result).isNotNull() + assertThat(result!!.amount!!.compareTo(BigDecimal("1"))).isEqualTo(0) + } + + // endregion + + // region Unsupported network + + @Test + fun `no matching tron coin returns UnsupportedNetwork`() { + val result = parser.parse( + qrCode = "tron:TAddress", + coins = emptyList(), + allCurrencies = emptyList(), + ) + + assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java) + } + + // endregion + + // region Helpers + + private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? { + return (this as? PaymentUriParser.ParseResult.Success)?.content + } + + private val tronNetwork = buildNetwork("TRON", "Tron", "TRX") + + private val tronCoin = CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("TRON"), + suffix = CryptoCurrency.ID.Suffix.RawID("TRON"), + ), + network = tronNetwork, + name = "Tron", + symbol = "TRX", + decimals = 6, + iconUrl = null, + isCustom = false, + ) + + private val usdtToken = CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("TRON"), + suffix = CryptoCurrency.ID.Suffix.RawID("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"), + ), + network = tronNetwork, + name = "Tether USD", + symbol = "USDT", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + ) + + private fun buildNetwork(rawNetworkId: String, name: String, symbol: String): Network { + return Network( + id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), + backendId = rawNetworkId, + name = name, + currencySymbol = symbol, + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = false, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + // endregion +} \ No newline at end of file