Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-25 15:42:38 +03:00
parent 29f8becc48
commit 041ff02106
13 changed files with 706 additions and 85 deletions

View file

@ -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(),
),
),
)

View file

@ -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<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): 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<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
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<String, Set<Blockchain>> = BLOCKCHAINS
.flatMap { blockchain -> blockchain.getShareScheme().map { scheme -> scheme to blockchain } }
.groupBy({ it.first }, { it.second })
.mapValues { it.value.toSet() }
}
}

View file

@ -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<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): 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<CryptoCurrency.Coin>): List<CryptoCurrency.Coin> {
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:<address>[@<chainId>][/<function>][?<params>]
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"

View file

@ -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<CryptoCurrency.Token>()
.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<CryptoCurrency.Coin>,
val matchingNetworkIds: Set<Network.ID>,
val allCurrencies: List<CryptoCurrency>,
val blockchainName: String,
val qrCode: String,
)
}

View file

@ -77,7 +77,6 @@ internal class QrContentClassifierParser(
}
internal interface BlockchainDataProvider {
fun getShareSchemes(network: Network): List<String>
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<String> {
return runCatching { network.toBlockchain().getShareScheme() }.getOrDefault(emptyList())
}
override fun validateAddress(network: Network, address: String): Boolean {
return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false)
}

View file

@ -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"

View file

@ -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<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): 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"
}
}

View file

@ -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<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): 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"
}
}

View file

@ -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<QrContentClassifierParser.BlockchainDataProvider> {
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"),

View file

@ -15,7 +15,6 @@ import java.math.BigDecimal
internal class Eip681PaymentUriParserTest {
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
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",

View file

@ -14,7 +14,6 @@ import java.math.BigDecimal
internal class QrContentClassifierTest {
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
every { getShareSchemes(any()) } returns emptyList()
every { validateAddress(any(), any()) } returns false
every { getChainId(any()) } returns null
every { findSupportedBlockchainName(any()) } returns null

View file

@ -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
}

View file

@ -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
}