Updated on 2026-08-14
This commit is contained in:
parent
8a4dbe1b4c
commit
8a79e65e2e
18 changed files with 1099 additions and 101 deletions
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.data.qrscanning.di
|
||||
|
||||
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
|
||||
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
|
||||
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
||||
import dagger.Module
|
||||
|
|
@ -15,6 +16,11 @@ internal object QrScanningDataModule {
|
|||
@Provides
|
||||
@Singleton
|
||||
fun provideQrScanningEventsRepository(): QrScanningEventsRepository {
|
||||
return DefaultQrScanningEventsRepository()
|
||||
return DefaultQrScanningEventsRepository(
|
||||
qrContentClassifierParser = QrContentClassifierParser(
|
||||
QrContentClassifierParser.DefaultBlockchainDataProvider
|
||||
(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package com.tangem.data.qrscanning.parser
|
||||
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import java.net.URLDecoder
|
||||
|
||||
internal class QrContentClassifierParser(
|
||||
private val blockchainDataProvider: BlockchainDataProvider,
|
||||
private val paymentUriParser: QrSentUriParser = QrSentUriParser(),
|
||||
) {
|
||||
|
||||
fun parse(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
|
||||
if (qrCode.startsWith(WC_PREFIX)) {
|
||||
return ClassifiedQrContent.WalletConnect(qrCode)
|
||||
}
|
||||
|
||||
if (isDAppWcUrl(qrCode)) {
|
||||
return ClassifiedQrContent.WalletConnect(qrCode)
|
||||
}
|
||||
|
||||
val coins = userCurrencies.filterIsInstance<CryptoCurrency.Coin>()
|
||||
val uniqueCoins = coins.distinctBy { it.network.id }
|
||||
|
||||
val paymentUri = tryParsePaymentUri(qrCode, uniqueCoins)
|
||||
if (paymentUri != null) return paymentUri
|
||||
|
||||
val matchingCurrencies = uniqueCoins.filter { coin ->
|
||||
blockchainDataProvider.validateAddress(coin.network, qrCode)
|
||||
}
|
||||
|
||||
if (matchingCurrencies.isNotEmpty()) {
|
||||
return ClassifiedQrContent.PlainAddress(
|
||||
address = qrCode,
|
||||
matchingCurrencies = matchingCurrencies,
|
||||
)
|
||||
}
|
||||
|
||||
return ClassifiedQrContent.Unknown(qrCode)
|
||||
}
|
||||
|
||||
private fun tryParsePaymentUri(qrCode: String, coins: List<CryptoCurrency.Coin>): ClassifiedQrContent.PaymentUri? {
|
||||
return coins.firstNotNullOfOrNull { coin ->
|
||||
val matchedScheme = blockchainDataProvider.getShareSchemes(coin.network)
|
||||
.sortedByDescending { it.length }
|
||||
.firstOrNull { qrCode.startsWith(it) }
|
||||
?: return@firstNotNullOfOrNull null
|
||||
|
||||
val withoutScheme = qrCode.removePrefix(matchedScheme)
|
||||
val parsed = paymentUriParser.parse(withoutScheme) ?: return@firstNotNullOfOrNull null
|
||||
|
||||
ClassifiedQrContent.PaymentUri(
|
||||
currency = coin,
|
||||
address = parsed.address,
|
||||
amount = parsed.amount,
|
||||
memo = parsed.memo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDAppWcUrl(qrCode: String): Boolean {
|
||||
if (!qrCode.startsWith(HTTP_PREFIX) && !qrCode.startsWith(HTTPS_PREFIX)) return false
|
||||
|
||||
val uriParam = paymentUriParser.extractParameters(qrCode)[PARAM_URI] ?: return false
|
||||
val decodedUri = runCatching { URLDecoder.decode(
|
||||
uriParam,
|
||||
QrSentUriParser.CHARSET_UTF8,
|
||||
) }.getOrDefault(uriParam)
|
||||
return decodedUri.startsWith(WC_PREFIX)
|
||||
}
|
||||
|
||||
internal interface BlockchainDataProvider {
|
||||
fun getShareSchemes(network: Network): List<String>
|
||||
fun validateAddress(network: Network, address: String): Boolean
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HTTP_PREFIX = "http://"
|
||||
const val HTTPS_PREFIX = "https://"
|
||||
const val PARAM_URI = "uri"
|
||||
const val WC_PREFIX = "wc:"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.tangem.data.qrscanning.parser
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.net.URLDecoder
|
||||
|
||||
internal class QrSentUriParser {
|
||||
|
||||
data class Result(
|
||||
val address: String,
|
||||
val amount: BigDecimal?,
|
||||
val memo: String?,
|
||||
val params: Map<String, String>,
|
||||
)
|
||||
|
||||
fun parse(withoutScheme: String): Result? {
|
||||
val address = withoutScheme.takeWhile {
|
||||
it != CHAIN_DELIMITER && it != FUNCTION_DELIMITER && it != PARAM_DELIMITER
|
||||
}
|
||||
if (address.isBlank()) return null
|
||||
|
||||
val params = extractParameters(withoutScheme)
|
||||
val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull()
|
||||
val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let {
|
||||
runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it)
|
||||
}
|
||||
|
||||
return Result(
|
||||
address = address,
|
||||
amount = amount,
|
||||
memo = memo,
|
||||
params = params,
|
||||
)
|
||||
}
|
||||
|
||||
fun extractParameters(from: String): Map<String, String> {
|
||||
val paramsBlock = from.substringAfter(PARAM_DELIMITER, missingDelimiterValue = "")
|
||||
if (paramsBlock.isBlank()) return emptyMap()
|
||||
|
||||
return paramsBlock.split(PARAMS_DELIMITER)
|
||||
.mapNotNull { param ->
|
||||
val parts = param.split(PARAM_VALUE_DELIMITER, limit = 2)
|
||||
if (parts.size == 2) parts[0].lowercase() to parts[1] else null
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHAIN_DELIMITER = '@'
|
||||
const val FUNCTION_DELIMITER = '/'
|
||||
const val PARAM_DELIMITER = '?'
|
||||
const val PARAMS_DELIMITER = '&'
|
||||
const val PARAM_VALUE_DELIMITER = '='
|
||||
const val PARAM_AMOUNT = "amount"
|
||||
const val PARAM_MEMO = "memo"
|
||||
const val PARAM_MESSAGE = "message"
|
||||
const val PARAM_ADDRESS = "address"
|
||||
const val PARAM_VALUE = "value"
|
||||
const val PARAM_UINT256 = "uint256"
|
||||
const val CHARSET_UTF8 = "UTF-8"
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,10 @@ package com.tangem.data.qrscanning.repository
|
|||
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.core.ui.utils.parseBigDecimalOrNull
|
||||
import com.tangem.data.qrscanning.parser.QrSentUriParser
|
||||
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import com.tangem.domain.qrscanning.models.QrResult
|
||||
import com.tangem.domain.qrscanning.models.RawQrResult
|
||||
import com.tangem.domain.qrscanning.models.SourceType
|
||||
|
|
@ -14,9 +17,11 @@ import kotlinx.coroutines.flow.map
|
|||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.yield
|
||||
import java.math.BigDecimal
|
||||
import java.net.URLDecoder
|
||||
|
||||
internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
|
||||
internal class DefaultQrScanningEventsRepository(
|
||||
private val qrContentClassifierParser: QrContentClassifierParser,
|
||||
private val paymentUriParser: QrSentUriParser = QrSentUriParser(),
|
||||
) : QrScanningEventsRepository {
|
||||
|
||||
private data class QrScanningEvent(val qrCode: RawQrResult)
|
||||
|
||||
|
|
@ -37,120 +42,50 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
|
|||
|
||||
override fun parseQrCode(qrCode: String, cryptoCurrency: CryptoCurrency): QrResult {
|
||||
val withoutSchema = stripSchema(qrCode, cryptoCurrency)
|
||||
val parsed = paymentUriParser.parse(withoutSchema)
|
||||
?: return QrResult(address = withoutSchema)
|
||||
|
||||
// A poor man's ERC-681 parser: we want to extract only the destination address, and we don't care
|
||||
// about other parts of the ERC-681 payload string like `chain_id` and/or `function_name`.
|
||||
//
|
||||
// We're extracting the destination address by parsing the given string until we meet
|
||||
// any of the possible string delimiters (@ ? /).
|
||||
val address = withoutSchema.takeWhile { char ->
|
||||
char != CHAIN_DELIMITER && char != FUNCTION_DELIMITER && char != PARAM_DELIMITER
|
||||
val result = QrResult(address = parsed.address)
|
||||
result.amount = parsed.amount
|
||||
result.memo = parsed.memo
|
||||
|
||||
// ERC-681: if 'address' parameter exists, currency must be a token,
|
||||
// and the URI address must match the token's contract address.
|
||||
parsed.params[QrSentUriParser.PARAM_ADDRESS]?.let { addressValue ->
|
||||
val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult()
|
||||
if (tokenCurrency.contractAddress.equals(parsed.address, ignoreCase = true)) {
|
||||
result.address = addressValue
|
||||
} else {
|
||||
return QrResult()
|
||||
}
|
||||
}
|
||||
|
||||
val result = QrResult(address = address)
|
||||
|
||||
extractParameters(withoutSchema)
|
||||
.forEach { entry ->
|
||||
when (entry.key) {
|
||||
Parameter.Amount -> {
|
||||
// According to BIP-0021, the value is specified in decimals. No conversion needed
|
||||
result.amount = entry.value.parseBigDecimalOrNull()
|
||||
}
|
||||
Parameter.Message,
|
||||
Parameter.Memo,
|
||||
-> {
|
||||
result.memo = URLDecoder.decode(entry.value, "UTF-8")
|
||||
}
|
||||
Parameter.Address -> {
|
||||
// If 'address' parameter is exists, then currency must be TOKEN.
|
||||
val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult()
|
||||
|
||||
// Overrides destination address for token transfers (ERC-681)
|
||||
// `address` parameter is used only if the contract address, encoded in the QR,
|
||||
// matches the contract address of the token.
|
||||
// Otherwise, the scanned string is likely malformed, and we stop the entire parsing routin
|
||||
if (tokenCurrency.contractAddress.equals(address, ignoreCase = true)) {
|
||||
result.address = entry.value
|
||||
} else {
|
||||
return QrResult()
|
||||
}
|
||||
}
|
||||
Parameter.Value,
|
||||
Parameter.Uint256,
|
||||
-> {
|
||||
// Extra convert parses scientific notation to decimal
|
||||
// This is necessary to be able comparing BigDecimal values
|
||||
result.amount = entry.value.parseBigDecimalOrNull()
|
||||
?.toPlainString()?.toBigDecimalOrNull()
|
||||
?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals))
|
||||
}
|
||||
}
|
||||
}
|
||||
// ERC-681: value/uint256 is in the smallest unit, needs conversion
|
||||
val valueStr = parsed.params[QrSentUriParser.PARAM_VALUE]
|
||||
?: parsed.params[QrSentUriParser.PARAM_UINT256]
|
||||
if (valueStr != null) {
|
||||
result.amount = valueStr.parseBigDecimalOrNull()
|
||||
?.toPlainString()?.toBigDecimalOrNull()
|
||||
?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun classify(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
|
||||
return qrContentClassifierParser.parse(qrCode, userCurrencies)
|
||||
}
|
||||
|
||||
private fun stripSchema(raw: String, currency: CryptoCurrency): String {
|
||||
val qrSchemas = currency.network.toBlockchain().getShareScheme()
|
||||
|
||||
// The most specific (i.e. the most lengthy) prefixes always come first
|
||||
qrSchemas
|
||||
.sortedByDescending { it.length }
|
||||
.forEach { schema ->
|
||||
val stripped = raw.split(schema)
|
||||
|
||||
if (stripped.size > 1) return stripped.last()
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
private fun extractParameters(from: String): Map<Parameter, String> {
|
||||
val parametersBlock = from.substringAfter(PARAM_DELIMITER)
|
||||
if (parametersBlock.isBlank()) return emptyMap()
|
||||
|
||||
val paramList = parametersBlock.split(PARAMS_DELIMITER)
|
||||
.mapNotNull { param ->
|
||||
val parameterWithValue = param.split(PARAM_VALUE_DELIMITER)
|
||||
if (parameterWithValue.size == 2) {
|
||||
val name = Parameter.getParam(parameterWithValue.first())
|
||||
val value = parameterWithValue.last()
|
||||
|
||||
if (name != null) {
|
||||
name to value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.associate { it }
|
||||
|
||||
return paramList
|
||||
}
|
||||
|
||||
private enum class Parameter {
|
||||
Amount,
|
||||
Message,
|
||||
Memo,
|
||||
Address,
|
||||
Value,
|
||||
Uint256,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun getParam(name: String): Parameter? {
|
||||
return Parameter.entries.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// See https://eips.ethereum.org/EIPS/eip-681 for details.
|
||||
const val CHAIN_DELIMITER = '@' // ERC-681 [ "@" chain_id ]
|
||||
const val FUNCTION_DELIMITER = '/' // ERC-681 [ "/" function_name ]
|
||||
const val PARAM_DELIMITER = '?' // BIP-021, ERC-681
|
||||
const val PARAMS_DELIMITER = '&'
|
||||
const val PARAM_VALUE_DELIMITER = '='
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.tangem.data.qrscanning
|
|||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
|
||||
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
|
|
@ -13,7 +14,8 @@ import java.math.BigDecimal
|
|||
|
||||
internal class DefaultQrScanningEventsRepositoryTest {
|
||||
|
||||
private val repository = DefaultQrScanningEventsRepository()
|
||||
private val qrContentClassifier: QrContentClassifierParser = mockk()
|
||||
private val repository = DefaultQrScanningEventsRepository(qrContentClassifier)
|
||||
|
||||
private val cryptoCurrencyId = mockk<CryptoCurrency.ID>()
|
||||
private val network = mockk<Network>()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,305 @@
|
|||
package com.tangem.data.qrscanning
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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 QrContentClassifierTest {
|
||||
|
||||
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
|
||||
every { getShareSchemes(any()) } returns emptyList()
|
||||
every { validateAddress(any(), any()) } returns false
|
||||
}
|
||||
private val classifier = QrContentClassifierParser(blockchainDataProvider)
|
||||
|
||||
// region WalletConnect
|
||||
|
||||
@Test
|
||||
fun `WalletConnect URI is classified correctly`() {
|
||||
val uri = "wc:a4f86d5-72ac-46ad-a1aa-9e@1-f5c0c@2"
|
||||
val result = classifier.parse(uri, listOf(bitcoinCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
|
||||
assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(uri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WalletConnect URI takes priority over address matching`() {
|
||||
val uri = "wc:something"
|
||||
every { blockchainDataProvider.validateAddress(any(), uri) } returns true
|
||||
|
||||
val result = classifier.parse(uri, listOf(bitcoinCoin, ethereumCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dApp URL with wc uri query param is classified as WalletConnect`() {
|
||||
val dAppUrl = "https://uniswap.org/app/wc?uri=wc:6ea45@2?relay-protocol=irn&symKey=f2de&expiryTimestamp=123"
|
||||
|
||||
val result = classifier.parse(dAppUrl, listOf(bitcoinCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
|
||||
assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(dAppUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HTTP URL without wc uri param is not classified as WalletConnect`() {
|
||||
val url = "https://example.com/page?foo=bar"
|
||||
|
||||
val result = classifier.parse(url, listOf(bitcoinCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HTTP URL with uri param not starting with wc is not WalletConnect`() {
|
||||
val url = "https://example.com/page?uri=https://other.com"
|
||||
|
||||
val result = classifier.parse(url, listOf(bitcoinCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region PaymentUri
|
||||
|
||||
@Test
|
||||
fun `Bitcoin BIP-021 URI with amount is parsed`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5"
|
||||
val result = classifier.parse(qr, listOf(bitcoinCoin, ethereumCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
|
||||
val paymentUri = result as ClassifiedQrContent.PaymentUri
|
||||
assertThat(paymentUri.currency).isEqualTo(bitcoinCoin)
|
||||
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
|
||||
assertThat(paymentUri.amount).isEqualTo(BigDecimal("0.5"))
|
||||
assertThat(paymentUri.memo).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Bitcoin URI without params returns address only`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
|
||||
val result = classifier.parse(qr, listOf(bitcoinCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
|
||||
val paymentUri = result as ClassifiedQrContent.PaymentUri
|
||||
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
|
||||
assertThat(paymentUri.amount).isNull()
|
||||
assertThat(paymentUri.memo).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Bitcoin URI with message param is parsed as memo`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.0&message=test%20memo"
|
||||
val result = classifier.parse(qr, listOf(bitcoinCoin))
|
||||
|
||||
val paymentUri = result as ClassifiedQrContent.PaymentUri
|
||||
assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.0"))
|
||||
assertThat(paymentUri.memo).isEqualTo("test memo")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `URI with memo parameter is parsed`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?memo=hello"
|
||||
val result = classifier.parse(qr, listOf(bitcoinCoin))
|
||||
|
||||
val paymentUri = result as ClassifiedQrContent.PaymentUri
|
||||
assertThat(paymentUri.memo).isEqualTo("hello")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Ethereum ERC-681 URI with chain_id and function is parsed`() {
|
||||
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:")
|
||||
|
||||
val qr = "ethereum:0x1234567890abcdef1234567890abcdef12345678@1/transfer?amount=1.5"
|
||||
val result = classifier.parse(qr, listOf(ethereumCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
|
||||
val paymentUri = result as ClassifiedQrContent.PaymentUri
|
||||
assertThat(paymentUri.address).isEqualTo("0x1234567890abcdef1234567890abcdef12345678")
|
||||
assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `URI scheme not matching user currencies falls through`() {
|
||||
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
|
||||
val result = classifier.parse(qr, listOf(ethereumCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Longest matching scheme is preferred`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns
|
||||
listOf("bitcoin:", "bitcoin://")
|
||||
|
||||
val qr = "bitcoin://1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
|
||||
val result = classifier.parse(qr, listOf(bitcoinCoin))
|
||||
|
||||
val paymentUri = result as ClassifiedQrContent.PaymentUri
|
||||
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region PlainAddress
|
||||
|
||||
@Test
|
||||
fun `Plain address matches single currency`() {
|
||||
val address = "0x1234567890abcdef1234567890abcdef12345678"
|
||||
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
|
||||
|
||||
val result = classifier.parse(address, listOf(ethereumCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
|
||||
val plain = result as ClassifiedQrContent.PlainAddress
|
||||
assertThat(plain.address).isEqualTo(address)
|
||||
assertThat(plain.matchingCurrencies).containsExactly(ethereumCoin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Plain address matches multiple currencies`() {
|
||||
val address = "0x1234567890abcdef1234567890abcdef12345678"
|
||||
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
|
||||
every { blockchainDataProvider.validateAddress(bscCoin.network, address) } returns true
|
||||
|
||||
val result = classifier.parse(address, listOf(ethereumCoin, bscCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
|
||||
val plain = result as ClassifiedQrContent.PlainAddress
|
||||
assertThat(plain.matchingCurrencies).hasSize(2)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Unknown
|
||||
|
||||
@Test
|
||||
fun `Random string returns Unknown`() {
|
||||
val result = classifier.parse("hello world", listOf(bitcoinCoin, ethereumCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
|
||||
assertThat((result as ClassifiedQrContent.Unknown).raw).isEqualTo("hello world")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Empty string returns Unknown`() {
|
||||
val result = classifier.parse("", listOf(bitcoinCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Empty currencies list returns Unknown`() {
|
||||
val result = classifier.parse("0x1234567890abcdef1234567890abcdef12345678", emptyList())
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Edge cases
|
||||
|
||||
@Test
|
||||
fun `Tokens are filtered out, only Coins are used`() {
|
||||
val token = CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = buildNetwork("ethereum"),
|
||||
name = "USDT",
|
||||
symbol = "USDT",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
||||
)
|
||||
|
||||
val result = classifier.parse("0x1234", listOf(token))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Duplicate coins with same network are deduplicated`() {
|
||||
val address = "0x1234567890abcdef1234567890abcdef12345678"
|
||||
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
|
||||
|
||||
val result = classifier.parse(address, listOf(ethereumCoin, ethereumCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
|
||||
val plain = result as ClassifiedQrContent.PlainAddress
|
||||
assertThat(plain.matchingCurrencies).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Payment URI takes priority over plain address match`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
every { blockchainDataProvider.validateAddress(bitcoinCoin.network, any()) } returns true
|
||||
|
||||
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1"
|
||||
val result = classifier.parse(qr, listOf(bitcoinCoin))
|
||||
|
||||
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Helpers
|
||||
|
||||
private val bitcoinCoin = buildCoin("bitcoin")
|
||||
private val ethereumCoin = buildCoin("ethereum")
|
||||
private val bscCoin = buildCoin("bsc")
|
||||
|
||||
private fun buildCoin(rawNetworkId: String): CryptoCurrency.Coin {
|
||||
return CryptoCurrency.Coin(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
|
||||
),
|
||||
network = buildNetwork(rawNetworkId),
|
||||
name = rawNetworkId,
|
||||
symbol = rawNetworkId.take(3).uppercase(),
|
||||
decimals = 8,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNetwork(rawNetworkId: String): Network {
|
||||
return Network(
|
||||
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
|
||||
backendId = rawNetworkId,
|
||||
name = rawNetworkId,
|
||||
currencySymbol = rawNetworkId.take(3).uppercase(),
|
||||
derivationPath = Network.DerivationPath.None,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = false,
|
||||
canHandleTokens = false,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue