Updated on 2026-08-14

This commit is contained in:
Tangem 2024-03-14 23:32:11 +05:00
parent 263ee55122
commit 0f600e9dcf
12 changed files with 489 additions and 11 deletions

View file

@ -15,8 +15,20 @@ dependencies {
/** Domain */
implementation(projects.domain.qrScanning)
implementation(projects.domain.qrScanning.models)
implementation(projects.domain.tokens.models)
implementation(projects.core.ui)
/** SdK */
implementation(deps.tangem.blockchain)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(deps.test.junit)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
}

View file

@ -1,10 +1,15 @@
package com.tangem.data.qrscanning.repository
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.qrscanning.models.QrResult
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import com.tangem.domain.tokens.model.CryptoCurrency
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
import java.net.URLDecoder
internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
@ -19,4 +24,122 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
override fun subscribeToScanningResults(type: SourceType) = scannedEvents
.filter { it.type == type }
.map { it.qrCode }
override fun parseQrCode(qrCode: String, cryptoCurrency: CryptoCurrency): QrResult {
val withoutSchema = stripSchema(qrCode, cryptoCurrency)
// 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 = address)
extractParameters(withoutSchema)
.forEach {
when (it.key) {
Parameter.Amount -> {
// According to BIP-0021, the value is specified in decimals. No conversion needed
result.amount = it.value.toBigDecimalOrNull()
}
Parameter.Message,
Parameter.Memo,
-> {
result.memo = URLDecoder.decode(it.value, "UTF-8")
}
Parameter.Address -> {
// Overrides destination address for token transfers (ERC-681)
if (cryptoCurrency is CryptoCurrency.Token) {
// `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 routine
if (cryptoCurrency.contractAddress.equals(address, ignoreCase = true)) {
result.address = it.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 = it.value.toBigDecimalOrNull()
?.toPlainString()?.toBigDecimalOrNull()
?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals))
}
}
}
return result
}
private fun stripSchema(raw: String, currency: CryptoCurrency): String {
val qrSchemas = Blockchain.fromId(currency.network.id.value).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 = '='
}
}

View file

@ -0,0 +1,253 @@
package com.tangem.data.qrscanning
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
import com.tangem.domain.qrscanning.models.QrResult
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import java.math.BigDecimal
internal class DefaultQrScanningEventsRepositoryTest {
private val repository = DefaultQrScanningEventsRepository()
private val cryptoCurrencyId = mockk<CryptoCurrency.ID>()
private val network = mockk<Network>()
private val cryptoCurrency = CryptoCurrency.Coin(
id = cryptoCurrencyId,
network = network,
name = "blockchain",
symbol = "symbol",
decimals = 18,
iconUrl = null,
isCustom = false,
)
private val tokenCryptoCurrency = CryptoCurrency.Token(
id = cryptoCurrencyId,
network = network,
name = "blockchain",
symbol = "symbol",
decimals = 7,
iconUrl = null,
isCustom = false,
contractAddress = "0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7",
)
private val garbage = "some_garbage="
private val schema1 = "bitcoin"
private val schema2 = "ethereum"
private val address1 = "bc1pw83rs5s75na2g7ec8yqgekr3ae209ye7ck2ftakjnh8tv3xzw8ls6wgt62"
private val address2 = "0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"
private val address3 = "pay-0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"
private val address4 = "0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7"
private val function = "/transfer"
private val someParam = "someParam"
private val someParamValue = "someParamValue"
private val addressParam = "address"
private val addressParamValue = "0xc00f86ab93cd0bd3a60213583d0fe35aaa1ace23"
private val amountParam = "amount"
private val someAmountParamValue = "amount"
private val amountParamValue = "123.123"
private val valueParam = "value"
private val someValueParamValue = "amount"
private val valueParamValue2 = "1.88e10"
private val valueParamValue3 = "1.68E11"
private val valueParamValue4 = "23000000000"
private val memoParam = "memo"
private val memoParamValue = "a%20random%20memo"
private val memoParamValueUtf8 = "a random memo"
private val messageParam = "message"
private val messageParamValue = "some%20message"
private val messageParamValueUft8 = "some message"
private val messageParamValue2 = "message"
@Test
fun testBip021() {
every { network.id } returns Network.ID(Blockchain.Bitcoin.id)
positiveCase(
"$schema1:$address1",
QrResult(address = address1),
cryptoCurrency,
)
positiveCase(
"$garbage$schema1:$address1",
QrResult(address = address1),
cryptoCurrency,
)
positiveCase(
"$address1?$someParam=$someParamValue",
QrResult(address = address1),
cryptoCurrency,
)
positiveCase(
"$address1?$someParam=$someParamValue&$amountParam=$someAmountParamValue",
QrResult(address = address1),
cryptoCurrency,
)
positiveCase(
"$address1?$someParam=$someParamValue&$amountParam=$amountParamValue",
QrResult(address = address1, amount = BigDecimal(amountParamValue)),
cryptoCurrency,
)
positiveCase(
"$address1?$someParam=$someParamValue&$amountParam=$amountParamValue&$memoParam=$memoParamValue",
QrResult(address = address1, amount = BigDecimal(amountParamValue), memo = memoParamValueUtf8),
cryptoCurrency,
)
positiveCase(
"$address1?$someParam=$someParamValue&$messageParam=$messageParamValue",
QrResult(address = address1, memo = messageParamValueUft8),
cryptoCurrency,
)
positiveCase(
"$address1?$someParam=$someParamValue&$messageParam=$messageParamValue2",
QrResult(address = address1, memo = messageParamValue2),
cryptoCurrency,
)
negativeCase(
"$address1?$someParam=$someParamValue&$amountParam=$amountParamValue",
QrResult(address = address1, memo = messageParamValue2),
cryptoCurrency,
)
}
@Test
fun testErc681Coin() {
every { network.id } returns Network.ID(Blockchain.Ethereum.id)
positiveCase(
address2,
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$address2?$someParam=$someParamValue",
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$schema2:$address2",
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$schema2:$address3",
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2",
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2$function?$addressParam=$addressParamValue",
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$someAmountParamValue",
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$valueParamValue2",
QrResult(address = address2, amount = BigDecimal("0.0000000188")),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$valueParamValue3",
QrResult(address = address2, amount = BigDecimal("0.000000168")),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$valueParamValue4",
QrResult(address = address2, amount = BigDecimal("0.000000023")),
cryptoCurrency,
)
}
@Test
fun testErc681Token() {
every { network.id } returns Network.ID(Blockchain.Ethereum.id)
positiveCase(
address2,
QrResult(address = address2),
tokenCryptoCurrency,
)
positiveCase(
"$address2?$someParam=$someParamValue",
QrResult(address = address2),
tokenCryptoCurrency,
)
positiveCase(
"$schema2:$address2",
QrResult(address = address2),
tokenCryptoCurrency,
)
positiveCase(
"$schema2:$address3",
QrResult(address = address2),
tokenCryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2",
QrResult(address = address2),
tokenCryptoCurrency,
)
positiveCase(
"$schema2:$address4$function?$addressParam=$addressParamValue",
QrResult(address = addressParamValue),
tokenCryptoCurrency,
)
positiveCase(
"$address2?$someParam=$someParamValue&$valueParam=$someValueParamValue",
QrResult(address = address2),
tokenCryptoCurrency,
)
positiveCase(
"$address2?$someParam=$someParamValue&$valueParam=$valueParamValue2",
QrResult(address = address2, amount = BigDecimal("1880")),
tokenCryptoCurrency,
)
positiveCase(
"$address2?$someParam=$someParamValue&$valueParam=$valueParamValue3",
QrResult(address = address2, amount = BigDecimal("16800")),
tokenCryptoCurrency,
)
positiveCase(
"$address2?$someParam=$someParamValue&$valueParam=$valueParamValue4",
QrResult(address = address2, amount = BigDecimal("2300")),
tokenCryptoCurrency,
)
negativeCase(
"$address2?$someParam=$someParamValue&$amountParam=$amountParamValue",
QrResult(address = address2, amount = BigDecimal("123.123"), memo = memoParamValueUtf8),
tokenCryptoCurrency,
)
}
private fun positiveCase(input: String, expected: QrResult, cryptoCurrency: CryptoCurrency) {
val actual = repository.parseQrCode(input, cryptoCurrency)
Truth.assertThat(actual.address).isEqualTo(expected.address)
Truth.assertThat(actual.amount).isEqualTo(expected.amount)
Truth.assertThat(actual.memo).isEqualTo(expected.memo)
}
private fun negativeCase(input: String, expected: QrResult, cryptoCurrency: CryptoCurrency) {
val actual = repository.parseQrCode(input, cryptoCurrency)
Truth.assertThat(actual).isNotEqualTo(expected)
}
}