Updated on 2026-08-14
This commit is contained in:
parent
29f8becc48
commit
041ff02106
13 changed files with 706 additions and 85 deletions
|
|
@ -3,6 +3,8 @@ package com.tangem.data.qrscanning.di
|
||||||
import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser
|
import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser
|
||||||
import com.tangem.data.qrscanning.parser.Eip681PaymentUriParser
|
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.parser.SolanaPaymentUriParser
|
||||||
|
import com.tangem.data.qrscanning.parser.TronPaymentUriParser
|
||||||
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
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
|
@ -24,7 +26,9 @@ internal object QrScanningDataModule {
|
||||||
blockchainDataProvider = blockchainDataProvider,
|
blockchainDataProvider = blockchainDataProvider,
|
||||||
paymentUriParsers = setOf(
|
paymentUriParsers = setOf(
|
||||||
Eip681PaymentUriParser(blockchainDataProvider),
|
Eip681PaymentUriParser(blockchainDataProvider),
|
||||||
Bip321PaymentUriParser(blockchainDataProvider),
|
TronPaymentUriParser(),
|
||||||
|
SolanaPaymentUriParser(),
|
||||||
|
Bip321PaymentUriParser(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package com.tangem.data.qrscanning.parser
|
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.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||||
|
|
||||||
internal class Bip321PaymentUriParser(
|
internal class Bip321PaymentUriParser(
|
||||||
private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider,
|
private val helper: PaymentUriResolveHelper = PaymentUriResolveHelper(),
|
||||||
) : PaymentUriParser {
|
) : PaymentUriParser {
|
||||||
|
|
||||||
override fun parse(
|
override fun parse(
|
||||||
|
|
@ -12,22 +14,24 @@ internal class Bip321PaymentUriParser(
|
||||||
coins: List<CryptoCurrency.Coin>,
|
coins: List<CryptoCurrency.Coin>,
|
||||||
allCurrencies: List<CryptoCurrency>,
|
allCurrencies: List<CryptoCurrency>,
|
||||||
): PaymentUriParser.ParseResult {
|
): 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
|
?: return PaymentUriParser.ParseResult.NotRecognized
|
||||||
val (matchingCoins, withoutScheme) = schemeAndRest
|
|
||||||
|
|
||||||
val parsed = QrSentUriParser().parse(withoutScheme)
|
val parsed = helper.parseUri(qrCode, scheme)
|
||||||
?: return PaymentUriParser.ParseResult.RecognizedError(
|
?: return PaymentUriParser.ParseResult.RecognizedError(
|
||||||
ClassifiedQrContent.Error.Unrecognized(qrCode),
|
ClassifiedQrContent.Error.Unrecognized(qrCode),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val matchingCoins = coins.filter { it.network.toBlockchain() in blockchains }
|
||||||
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
|
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
|
||||||
val matchingCurrencies = allCurrencies.filter { it.network.id in matchingNetworkIds }
|
val matchingCurrencies = allCurrencies.filter { it.network.id in matchingNetworkIds }
|
||||||
|
|
||||||
if (matchingCurrencies.isEmpty()) {
|
if (matchingCurrencies.isEmpty()) {
|
||||||
return PaymentUriParser.ParseResult.RecognizedError(
|
return PaymentUriParser.ParseResult.RecognizedError(
|
||||||
ClassifiedQrContent.Error.UnsupportedNetwork(
|
ClassifiedQrContent.Error.UnsupportedNetwork(
|
||||||
raw = qrCode,
|
raw = qrCode,
|
||||||
blockchain = matchingCoins.firstOrNull()?.network?.name,
|
blockchain = blockchains.first().fullName,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -42,22 +46,19 @@ internal class Bip321PaymentUriParser(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractSchemeAndRest(
|
private companion object {
|
||||||
qrCode: String,
|
val BLOCKCHAINS = listOf(
|
||||||
coins: List<CryptoCurrency.Coin>,
|
Blockchain.Bitcoin,
|
||||||
): Pair<List<CryptoCurrency.Coin>, String>? {
|
Blockchain.BitcoinTestnet,
|
||||||
for (coin in coins) {
|
Blockchain.Litecoin,
|
||||||
val schemes = blockchainDataProvider.getShareSchemes(coin.network)
|
Blockchain.Binance,
|
||||||
for (scheme in schemes) {
|
Blockchain.BinanceTestnet,
|
||||||
if (qrCode.startsWith(scheme, ignoreCase = true)) {
|
Blockchain.Dogecoin,
|
||||||
val withoutScheme = qrCode.removeRange(0, scheme.length)
|
Blockchain.XRP,
|
||||||
val allMatchingCoins = coins.filter { c ->
|
)
|
||||||
blockchainDataProvider.getShareSchemes(c.network).any { it.equals(scheme, ignoreCase = true) }
|
val SCHEME_TO_BLOCKCHAINS: Map<String, Set<Blockchain>> = BLOCKCHAINS
|
||||||
}
|
.flatMap { blockchain -> blockchain.getShareScheme().map { scheme -> scheme to blockchain } }
|
||||||
return allMatchingCoins to withoutScheme
|
.groupBy({ it.first }, { it.second })
|
||||||
}
|
.mapValues { it.value.toSet() }
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.tangem.data.qrscanning.parser
|
package com.tangem.data.qrscanning.parser
|
||||||
|
|
||||||
|
import com.tangem.blockchain.common.Blockchain
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
@ -14,11 +15,10 @@ internal class Eip681PaymentUriParser(
|
||||||
coins: List<CryptoCurrency.Coin>,
|
coins: List<CryptoCurrency.Coin>,
|
||||||
allCurrencies: List<CryptoCurrency>,
|
allCurrencies: List<CryptoCurrency>,
|
||||||
): PaymentUriParser.ParseResult {
|
): PaymentUriParser.ParseResult {
|
||||||
if (!qrCode.startsWith(SCHEME)) {
|
val scheme = SCHEMES.find { qrCode.startsWith(it, ignoreCase = true) }
|
||||||
return PaymentUriParser.ParseResult.NotRecognized
|
?: return PaymentUriParser.ParseResult.NotRecognized
|
||||||
}
|
|
||||||
|
|
||||||
val withoutScheme = qrCode.removePrefix(SCHEME)
|
val withoutScheme = qrCode.removePrefix(scheme)
|
||||||
val parsed = parseEip681(withoutScheme) ?: return PaymentUriParser.ParseResult.NotRecognized
|
val parsed = parseEip681(withoutScheme) ?: return PaymentUriParser.ParseResult.NotRecognized
|
||||||
|
|
||||||
val matchingCoins = findMatchingCoins(parsed.chainId, coins)
|
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> {
|
private fun findMatchingCoins(chainId: Long?, coins: List<CryptoCurrency.Coin>): List<CryptoCurrency.Coin> {
|
||||||
if (chainId == null) {
|
if (chainId == null) {
|
||||||
return coins.filter { coin ->
|
return coins.filter { coin ->
|
||||||
blockchainDataProvider.getShareSchemes(coin.network).any { it.startsWith(SCHEME) }
|
blockchainDataProvider.getChainId(coin.network) != null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return coins.filter { coin ->
|
return coins.filter { coin ->
|
||||||
|
|
@ -164,7 +164,7 @@ internal class Eip681PaymentUriParser(
|
||||||
private companion object {
|
private companion object {
|
||||||
// ethereum:<address>[@<chainId>][/<function>][?<params>]
|
// ethereum:<address>[@<chainId>][/<function>][?<params>]
|
||||||
val URI_REGEX = Regex("""^([^@/?]+)(?:@(\d+))?(?:/([^?]+))?(?:\?(.+))?$""")
|
val URI_REGEX = Regex("""^([^@/?]+)(?:@(\d+))?(?:/([^?]+))?(?:\?(.+))?$""")
|
||||||
const val SCHEME = "ethereum:"
|
val SCHEMES = Blockchain.Ethereum.getShareScheme()
|
||||||
const val FUNCTION_TRANSFER = "transfer"
|
const val FUNCTION_TRANSFER = "transfer"
|
||||||
const val PARAM_VALUE = "value"
|
const val PARAM_VALUE = "value"
|
||||||
const val PARAM_ADDRESS = "address"
|
const val PARAM_ADDRESS = "address"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -77,7 +77,6 @@ internal class QrContentClassifierParser(
|
||||||
}
|
}
|
||||||
|
|
||||||
internal interface BlockchainDataProvider {
|
internal interface BlockchainDataProvider {
|
||||||
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?
|
fun getChainId(network: Network): Long?
|
||||||
fun findSupportedBlockchainName(address: String): String?
|
fun findSupportedBlockchainName(address: String): String?
|
||||||
|
|
@ -85,10 +84,6 @@ internal class QrContentClassifierParser(
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class DefaultBlockchainDataProvider : BlockchainDataProvider {
|
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 {
|
override fun validateAddress(network: Network, address: String): Boolean {
|
||||||
return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false)
|
return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ internal class QrSentUriParser {
|
||||||
|
|
||||||
val params = extractParameters(withoutScheme)
|
val params = extractParameters(withoutScheme)
|
||||||
val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull()
|
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)
|
runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +53,7 @@ internal class QrSentUriParser {
|
||||||
const val PARAM_AMOUNT = "amount"
|
const val PARAM_AMOUNT = "amount"
|
||||||
const val PARAM_MEMO = "memo"
|
const val PARAM_MEMO = "memo"
|
||||||
const val PARAM_MESSAGE = "message"
|
const val PARAM_MESSAGE = "message"
|
||||||
|
const val PARAM_DT = "dt"
|
||||||
const val PARAM_ADDRESS = "address"
|
const val PARAM_ADDRESS = "address"
|
||||||
const val PARAM_VALUE = "value"
|
const val PARAM_VALUE = "value"
|
||||||
const val PARAM_UINT256 = "uint256"
|
const val PARAM_UINT256 = "uint256"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,28 +3,20 @@ 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.Bip321PaymentUriParser
|
import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser
|
||||||
import com.tangem.data.qrscanning.parser.PaymentUriParser
|
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.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.network.Network
|
import com.tangem.domain.models.network.Network
|
||||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||||
import io.mockk.every
|
|
||||||
import io.mockk.mockk
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
internal class Bip321PaymentUriParserTest {
|
internal class Bip321PaymentUriParserTest {
|
||||||
|
|
||||||
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
|
private val parser = Bip321PaymentUriParser()
|
||||||
every { getShareSchemes(any()) } returns emptyList()
|
|
||||||
}
|
|
||||||
private val parser = Bip321PaymentUriParser(blockchainDataProvider)
|
|
||||||
|
|
||||||
// region Basic parsing
|
// region Basic parsing
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `bitcoin URI with address and amount`() {
|
fun `bitcoin URI with address and amount`() {
|
||||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
|
||||||
|
|
||||||
val result = parser.parse(
|
val result = parser.parse(
|
||||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5",
|
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5",
|
||||||
coins = listOf(bitcoinCoin),
|
coins = listOf(bitcoinCoin),
|
||||||
|
|
@ -40,8 +32,6 @@ internal class Bip321PaymentUriParserTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `bitcoin URI with address only`() {
|
fun `bitcoin URI with address only`() {
|
||||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
|
||||||
|
|
||||||
val result = parser.parse(
|
val result = parser.parse(
|
||||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
|
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
|
||||||
coins = listOf(bitcoinCoin),
|
coins = listOf(bitcoinCoin),
|
||||||
|
|
@ -56,8 +46,6 @@ internal class Bip321PaymentUriParserTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `bitcoin URI with amount and message`() {
|
fun `bitcoin URI with amount and message`() {
|
||||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
|
||||||
|
|
||||||
val result = parser.parse(
|
val result = parser.parse(
|
||||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.23&message=Donation",
|
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.23&message=Donation",
|
||||||
coins = listOf(bitcoinCoin),
|
coins = listOf(bitcoinCoin),
|
||||||
|
|
@ -72,8 +60,6 @@ internal class Bip321PaymentUriParserTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `bitcoin URI with label and message`() {
|
fun `bitcoin URI with label and message`() {
|
||||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
|
||||||
|
|
||||||
val result = parser.parse(
|
val result = parser.parse(
|
||||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?label=Satoshi&message=Payment",
|
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?label=Satoshi&message=Payment",
|
||||||
coins = listOf(bitcoinCoin),
|
coins = listOf(bitcoinCoin),
|
||||||
|
|
@ -90,8 +76,6 @@ internal class Bip321PaymentUriParserTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `litecoin URI matches litecoin coin`() {
|
fun `litecoin URI matches litecoin coin`() {
|
||||||
every { blockchainDataProvider.getShareSchemes(litecoinCoin.network) } returns listOf("litecoin:")
|
|
||||||
|
|
||||||
val result = parser.parse(
|
val result = parser.parse(
|
||||||
qrCode = "litecoin:LcHKx4Tt97hnGgR3CRUiB1gSQ3F8wMozLj?amount=10",
|
qrCode = "litecoin:LcHKx4Tt97hnGgR3CRUiB1gSQ3F8wMozLj?amount=10",
|
||||||
coins = listOf(litecoinCoin),
|
coins = listOf(litecoinCoin),
|
||||||
|
|
@ -105,11 +89,21 @@ internal class Bip321PaymentUriParserTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `no matching scheme returns NotRecognized`() {
|
fun `dogecoin URI matches dogecoin coin`() {
|
||||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
|
||||||
|
|
||||||
val result = parser.parse(
|
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),
|
coins = listOf(bitcoinCoin),
|
||||||
allCurrencies = listOf(bitcoinCoin),
|
allCurrencies = listOf(bitcoinCoin),
|
||||||
)
|
)
|
||||||
|
|
@ -118,22 +112,18 @@ internal class Bip321PaymentUriParserTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `ethereum scheme matches as Success`() {
|
fun `ethereum scheme returns NotRecognized`() {
|
||||||
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:")
|
|
||||||
|
|
||||||
val result = parser.parse(
|
val result = parser.parse(
|
||||||
qrCode = "ethereum:0xRecipient?value=1000",
|
qrCode = "ethereum:0xRecipient?value=1000",
|
||||||
coins = listOf(ethereumCoin),
|
coins = listOf(bitcoinCoin),
|
||||||
allCurrencies = listOf(ethereumCoin),
|
allCurrencies = listOf(bitcoinCoin),
|
||||||
).asSuccess()
|
)
|
||||||
|
|
||||||
assertThat(result).isNotNull()
|
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `case insensitive scheme matching`() {
|
fun `case insensitive scheme matching`() {
|
||||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
|
||||||
|
|
||||||
val result = parser.parse(
|
val result = parser.parse(
|
||||||
qrCode = "Bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1",
|
qrCode = "Bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1",
|
||||||
coins = listOf(bitcoinCoin),
|
coins = listOf(bitcoinCoin),
|
||||||
|
|
@ -146,13 +136,26 @@ internal class Bip321PaymentUriParserTest {
|
||||||
|
|
||||||
// endregion
|
// 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
|
// region Includes tokens on matching network
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `includes tokens on matching network`() {
|
fun `includes tokens on matching network`() {
|
||||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
val btcToken = buildToken("BTC", "RUNE", "contractAddr")
|
||||||
|
|
||||||
val btcToken = buildToken("bitcoin", "RUNE", "contractAddr")
|
|
||||||
|
|
||||||
val result = parser.parse(
|
val result = parser.parse(
|
||||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.01",
|
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.01",
|
||||||
|
|
@ -192,8 +195,6 @@ internal class Bip321PaymentUriParserTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `bitcoin URI with memo param`() {
|
fun `bitcoin URI with memo param`() {
|
||||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
|
||||||
|
|
||||||
val result = parser.parse(
|
val result = parser.parse(
|
||||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&memo=TestMemo",
|
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&memo=TestMemo",
|
||||||
coins = listOf(bitcoinCoin),
|
coins = listOf(bitcoinCoin),
|
||||||
|
|
@ -212,20 +213,25 @@ internal class Bip321PaymentUriParserTest {
|
||||||
return (this as? PaymentUriParser.ParseResult.Success)?.content
|
return (this as? PaymentUriParser.ParseResult.Success)?.content
|
||||||
}
|
}
|
||||||
|
|
||||||
private val bitcoinCoin = buildCoin("bitcoin", decimals = 8)
|
private val bitcoinCoin = buildCoin("BTC", "Bitcoin", "BTC", decimals = 8)
|
||||||
private val litecoinCoin = buildCoin("litecoin", decimals = 8)
|
private val litecoinCoin = buildCoin("LTC", "Litecoin", "LTC", decimals = 8)
|
||||||
private val ethereumCoin = buildCoin("ethereum", decimals = 18)
|
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(
|
return CryptoCurrency.Coin(
|
||||||
id = CryptoCurrency.ID(
|
id = CryptoCurrency.ID(
|
||||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||||
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
|
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
|
||||||
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
|
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
|
||||||
),
|
),
|
||||||
network = buildNetwork(rawNetworkId),
|
network = buildNetwork(rawNetworkId, name, symbol),
|
||||||
name = rawNetworkId,
|
name = name,
|
||||||
symbol = rawNetworkId.take(3).uppercase(),
|
symbol = symbol,
|
||||||
decimals = decimals,
|
decimals = decimals,
|
||||||
iconUrl = null,
|
iconUrl = null,
|
||||||
isCustom = false,
|
isCustom = false,
|
||||||
|
|
@ -239,7 +245,7 @@ internal class Bip321PaymentUriParserTest {
|
||||||
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
|
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
|
||||||
suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress),
|
suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress),
|
||||||
),
|
),
|
||||||
network = buildNetwork(rawNetworkId),
|
network = buildNetwork(rawNetworkId, rawNetworkId, symbol),
|
||||||
name = symbol,
|
name = symbol,
|
||||||
symbol = symbol,
|
symbol = symbol,
|
||||||
decimals = 6,
|
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(
|
return Network(
|
||||||
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
|
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
|
||||||
backendId = rawNetworkId,
|
backendId = rawNetworkId,
|
||||||
name = rawNetworkId,
|
name = name,
|
||||||
currencySymbol = rawNetworkId.take(3).uppercase(),
|
currencySymbol = symbol,
|
||||||
derivationPath = Network.DerivationPath.None,
|
derivationPath = Network.DerivationPath.None,
|
||||||
isTestnet = false,
|
isTestnet = false,
|
||||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import java.math.BigDecimal
|
||||||
internal class Eip681PaymentUriParserTest {
|
internal class Eip681PaymentUriParserTest {
|
||||||
|
|
||||||
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
|
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
|
||||||
every { getShareSchemes(any()) } returns emptyList()
|
|
||||||
every { getChainId(any()) } returns null
|
every { getChainId(any()) } returns null
|
||||||
every { getBlockchainNameByChainId(any()) } returns null
|
every { getBlockchainNameByChainId(any()) } returns null
|
||||||
}
|
}
|
||||||
|
|
@ -56,8 +55,8 @@ internal class Eip681PaymentUriParserTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `native transfer without chain_id falls back to scheme matching`() {
|
fun `native transfer without chain_id falls back to chainId presence check`() {
|
||||||
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:")
|
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||||
|
|
||||||
val result = parser.parse(
|
val result = parser.parse(
|
||||||
qrCode = "ethereum:0xRecipient?value=1000000000000000000",
|
qrCode = "ethereum:0xRecipient?value=1000000000000000000",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import java.math.BigDecimal
|
||||||
internal class QrContentClassifierTest {
|
internal class QrContentClassifierTest {
|
||||||
|
|
||||||
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
|
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
|
||||||
every { getShareSchemes(any()) } returns emptyList()
|
|
||||||
every { validateAddress(any(), any()) } returns false
|
every { validateAddress(any(), any()) } returns false
|
||||||
every { getChainId(any()) } returns null
|
every { getChainId(any()) } returns null
|
||||||
every { findSupportedBlockchainName(any()) } returns null
|
every { findSupportedBlockchainName(any()) } returns null
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue