Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-02 15:06:56 +07:00
commit dcda504df6
40 changed files with 1442 additions and 182 deletions

@ -1 +1 @@
Subproject commit 636d7a8aa0e330e9b95e91d85f23ad15ac6d913f
Subproject commit 009cf6332a72cf0893167221abf7010d033906c2

View file

@ -2,6 +2,7 @@ package com.tangem.tap.di.domain
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.networks.repository.NetworksRepository
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
@ -41,11 +42,13 @@ internal object QrScanningDomainModule {
multiAccountListSupplier: MultiAccountListSupplier,
qrScanningEventsRepository: QrScanningEventsRepository,
userWalletsListRepository: UserWalletsListRepository,
networksRepository: NetworksRepository,
): ResolveQrSendTargetsUseCase {
return ResolveQrSendTargetsUseCase(
multiAccountListSupplier = multiAccountListSupplier,
qrScanningEventsRepository = qrScanningEventsRepository,
userWalletsListRepository = userWalletsListRepository,
networksRepository = networksRepository,
)
}
}

View file

@ -51,9 +51,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@Suppress("LongParameterList", "LargeClass")
@HiltViewModel
@ -142,10 +140,7 @@ internal class MainViewModel @Inject constructor(
viewModelScope.launch {
launchAPIRequests {
launch { blockchainSDKFactory.init() }
launch {
withTimeout(timeMillis = 1.seconds.inWholeMilliseconds) { fetchUserCountry() }
}
launch { fetchUserCountry() }
}
subscribeToSelectedWallet()

View file

@ -72,7 +72,12 @@ object NotificationsFactory {
)
is GetFeeError.BlockchainErrors.SuiOneCoinRequired ->
add(NotificationUM.Sui.NotEnoughCoinForTokenTransaction)
is GetFeeError.DataError -> when (feeError.cause) {
is GetFeeError.DataError -> when (val cause = feeError.cause) {
is BlockchainSdkError.Kaspa.DustChangeError -> add(
NotificationUM.Error.MinimumAmountError(
amount = cause.minimumAmount.format { crypto(tokenStatus.currency) },
),
)
BlockchainSdkError.TransactionDustChangeError -> add(
NotificationUM.Error.MinimumAmountError(
amount = dustValue.format { crypto(tokenStatus.currency) },
@ -388,6 +393,11 @@ object NotificationsFactory {
rentExemptionAmount = validationError.rentAmount,
cryptoCurrency = cryptoCurrency,
)
is BlockchainSdkError.Kaspa.DustChangeError -> add(
NotificationUM.Error.MinimumAmountError(
amount = validationError.minimumAmount.format { crypto(cryptoCurrency) },
),
)
is BlockchainSdkError.TransactionDustChangeError -> add(
NotificationUM.Error.MinimumAmountError(
amount = dustValue.format { crypto(cryptoCurrency) },

View file

@ -125,7 +125,7 @@ fun Transaction(state: TransactionState, isBalanceHidden: Boolean, modifier: Mod
top.linkTo(parent.top)
bottom.linkTo(timestampItem.top)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
width = Dimension.wrapContent
},
)
@ -274,6 +274,8 @@ private fun Amount(state: TransactionState, isBalanceHidden: Boolean, modifier:
}
},
style = TangemTheme.typography.body2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
is TransactionState.Loading -> {

View file

@ -57,7 +57,7 @@ internal class DefaultNetworksRepository(
.map { currency ->
CryptoCurrencyAddress(
cryptoCurrency = currency,
address = getDefaultAddress(userWalletId, network),
address = getDefaultAddress(userWalletId, network).orEmpty(),
)
}
}
@ -74,11 +74,17 @@ internal class DefaultNetworksRepository(
.map { currency ->
CryptoCurrencyAddress(
cryptoCurrency = currency,
address = getDefaultAddress(userWalletId, currency.network),
address = getDefaultAddress(userWalletId, currency.network).orEmpty(),
)
}
}
override suspend fun getDefaultAddress(userWalletId: UserWalletId, network: Network): String? {
return withContext(dispatchers.io) {
walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network)
}
}
override suspend fun hasCachedStatuses(userWalletId: UserWalletId): Boolean {
return networksStatusesStore.contains(userWalletId)
}
@ -100,10 +106,4 @@ internal class DefaultNetworksRepository(
networksStatusesStore.storeStatus(userWalletId = userWalletId, status = networkStatus)
}
private suspend fun getDefaultAddress(userWalletId: UserWalletId, network: Network): String {
return withContext(dispatchers.io) {
walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network).orEmpty()
}
}
}

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,6 +26,8 @@ internal object QrScanningDataModule {
blockchainDataProvider = blockchainDataProvider,
paymentUriParsers = setOf(
Eip681PaymentUriParser(blockchainDataProvider),
TronPaymentUriParser(blockchainDataProvider),
SolanaPaymentUriParser(blockchainDataProvider),
Bip321PaymentUriParser(blockchainDataProvider),
),
),

View file

@ -1,10 +1,13 @@
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,52 +15,69 @@ 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,
),
)
}
return PaymentUriParser.ParseResult.Success(
val isAddressValid = matchingCoins.any {
blockchainDataProvider.validateAddress(it.network, parsed.address)
}
if (!isAddressValid) {
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.Unrecognized(qrCode),
)
}
val result = PaymentUriParser.ParseResult.Success(
ClassifiedQrContent.PaymentUri(
address = parsed.address,
amount = parsed.amount,
memo = parsed.memo,
memo = parsed.memo?.second,
matchingCurrencies = matchingCurrencies,
),
)
val unconsumed = parsed.remainingParams - PARAM_LABEL
return helper.validateParams(
result = result,
unconsumedParams = unconsumed,
memo = parsed.memo,
matchingCoins = matchingCoins,
)
}
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 {
const val PARAM_LABEL = "label"
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)
@ -31,8 +31,26 @@ internal class Eip681PaymentUriParser(
)
}
val isAddressValid = matchingCoins.any { coin ->
blockchainDataProvider.validateAddress(coin.network, parsed.targetAddress)
}
if (!isAddressValid) {
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.Unrecognized(qrCode),
)
}
val result = if (parsed.functionName == FUNCTION_TRANSFER) {
if (PARAM_ADDRESS !in parsed.params) {
val recipient = parsed.params[Param.ADDRESS.key]
if (recipient == null) {
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.Unrecognized(qrCode),
)
}
val isRecipientValid = matchingCoins.any { coin ->
blockchainDataProvider.validateAddress(coin.network, recipient)
}
if (!isRecipientValid) {
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.Unrecognized(qrCode),
)
@ -41,16 +59,30 @@ internal class Eip681PaymentUriParser(
} else {
resolveNativeTransfer(parsed, matchingCoins, allCurrencies)
}
return if (result != null) {
PaymentUriParser.ParseResult.Success(result)
} else {
PaymentUriParser.ParseResult.RecognizedError(
if (result == null) {
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.UnsupportedNetwork(
raw = qrCode,
blockchain = matchingCoins.firstOrNull()?.network?.name,
),
)
}
val unsupportedParams = findUnsupportedParams(parsed.params, parsed.functionName)
return if (unsupportedParams.isEmpty()) {
PaymentUriParser.ParseResult.Success(result)
} else {
PaymentUriParser.ParseResult.SuccessWithWarning(result, unsupportedParams)
}
}
private fun findUnsupportedParams(params: Map<String, String>, functionName: String?): Map<String, String> {
val supportedKeys = if (functionName == FUNCTION_TRANSFER) {
Param.transferParams()
} else {
Param.nativeParams()
}
return params.filterKeys { key -> key !in supportedKeys }
}
private fun resolveNativeTransfer(
@ -58,7 +90,7 @@ internal class Eip681PaymentUriParser(
matchingCoins: List<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): ClassifiedQrContent.PaymentUri? {
val valueWei = parsed.params[PARAM_VALUE]?.toBigDecimalOrNull()
val valueWei = parsed.params[Param.VALUE.key]?.toBigDecimalOrNull()
if (matchingCoins.isEmpty()) return null
@ -87,7 +119,7 @@ internal class Eip681PaymentUriParser(
matchingCoins: List<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): ClassifiedQrContent.PaymentUri? {
val recipient = parsed.params[PARAM_ADDRESS] ?: return null
val recipient = parsed.params[Param.ADDRESS.key] ?: return null
val contractAddress = parsed.targetAddress
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
@ -100,7 +132,7 @@ internal class Eip681PaymentUriParser(
if (matchingTokens.isEmpty()) return null
val rawAmount = parsed.params[PARAM_UINT256]?.toBigDecimalOrNull()
val rawAmount = parsed.params[Param.UINT256.key]?.toBigDecimalOrNull()
val amount = rawAmount?.fromSmallestUnit(matchingTokens.first().decimals)
return ClassifiedQrContent.PaymentUri(
@ -114,7 +146,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 ->
@ -131,7 +163,7 @@ internal class Eip681PaymentUriParser(
val queryString = match.groupValues[GROUP_QUERY]
val params = parseQueryParams(queryString)
val chainId = pathChainId ?: params[PARAM_CHAIN_ID]?.toLongOrNull()
val chainId = pathChainId ?: params[Param.CHAIN_ID.key]?.toLongOrNull()
return Eip681Result(
targetAddress = targetAddress,
@ -161,15 +193,27 @@ internal class Eip681PaymentUriParser(
val params: Map<String, String>,
)
private enum class Param(val key: String) {
VALUE("value"),
ADDRESS("address"),
UINT256("uint256"),
CHAIN_ID("chainId"),
;
companion object {
/** Params supported for native transfers (no function or unknown function). */
fun nativeParams(): Set<String> = setOf(VALUE.key, CHAIN_ID.key)
/** Params supported for ERC-20 transfer() calls. */
fun transferParams(): Set<String> = setOf(ADDRESS.key, UINT256.key, CHAIN_ID.key)
}
}
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"
const val PARAM_UINT256 = "uint256"
const val PARAM_CHAIN_ID = "chainId"
const val GROUP_ADDRESS = 1
const val GROUP_CHAIN_ID = 2
const val GROUP_FUNCTION = 3

View file

@ -16,5 +16,11 @@ internal interface PaymentUriParser {
/** Successfully parsed with matching currencies. */
data class Success(val content: ClassifiedQrContent.PaymentUri) : ParseResult()
/** Successfully parsed but QR contains unsupported parameters. */
data class SuccessWithWarning(
val content: ClassifiedQrContent.PaymentUri,
val unsupportedParams: Map<String, String>,
) : ParseResult()
}
}

View file

@ -0,0 +1,112 @@
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?.second,
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?.second,
matchingCurrencies = matchingCurrencies,
),
)
}
fun validateParams(
result: PaymentUriParser.ParseResult,
unconsumedParams: Map<String, String>,
memo: Pair<String, String>?,
matchingCoins: List<CryptoCurrency.Coin>,
): PaymentUriParser.ParseResult {
if (result !is PaymentUriParser.ParseResult.Success) return result
val unsupported = buildMap {
putAll(unconsumedParams)
if (memo != null && !isMemoSupported(matchingCoins)) {
put(memo.first, memo.second)
}
}
return if (unsupported.isEmpty()) {
result
} else {
PaymentUriParser.ParseResult.SuccessWithWarning(result.content, unsupported)
}
}
private fun isMemoSupported(matchingCoins: List<CryptoCurrency.Coin>): Boolean {
return matchingCoins.any {
it.network.transactionExtrasType != Network.TransactionExtrasType.NONE
}
}
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

@ -26,6 +26,12 @@ internal class QrContentClassifierParser(
when (val paymentUriResult = tryParsePaymentUri(qrCode, uniqueCoins, userCurrencies)) {
is PaymentUriParser.ParseResult.Success -> return paymentUriResult.content
is PaymentUriParser.ParseResult.SuccessWithWarning -> {
return ClassifiedQrContent.PaymentUriWarning(
paymentUri = paymentUriResult.content,
unsupportedParams = paymentUriResult.unsupportedParams,
)
}
is PaymentUriParser.ParseResult.RecognizedError -> return paymentUriResult.error
is PaymentUriParser.ParseResult.NotRecognized -> Unit
}
@ -77,7 +83,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 +90,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

@ -8,8 +8,8 @@ internal class QrSentUriParser {
data class Result(
val address: String,
val amount: BigDecimal?,
val memo: String?,
val params: Map<String, String>,
val memo: Pair<String, String>?,
val remainingParams: Map<String, String>,
)
fun parse(withoutScheme: String): Result? {
@ -20,15 +20,24 @@ internal class QrSentUriParser {
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)
val memoKey = MemoParam.keys.firstOrNull { it in params }
val memo = memoKey?.let { key ->
val raw = params[key] ?: return@let null
val decoded = runCatching { URLDecoder.decode(raw, CHARSET_UTF8) }.getOrDefault(raw)
key to decoded
}
val consumedKeys = buildSet {
add(PARAM_AMOUNT)
addAll(MemoParam.keys)
}
return Result(
address = address,
amount = amount,
memo = memo,
params = params,
remainingParams = params - consumedKeys,
)
}
@ -44,6 +53,17 @@ internal class QrSentUriParser {
.toMap()
}
enum class MemoParam(val key: String) {
MEMO("memo"),
MESSAGE("message"),
DESTINATION_TAG("dt"),
;
companion object {
val keys = entries.map { it.key }.toSet()
}
}
companion object {
const val CHAIN_DELIMITER = '@'
const val FUNCTION_DELIMITER = '/'
@ -51,8 +71,6 @@ internal class QrSentUriParser {
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"

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
internal class SolanaPaymentUriParser(
private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider,
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 isAddressValid = matchingCoins.any {
blockchainDataProvider.validateAddress(it.network, parsed.address)
}
if (!isAddressValid) {
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.Unrecognized(qrCode),
)
}
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.remainingParams[PARAM_SPL_TOKEN]
val result = if (splTokenMint != null) {
helper.resolveTokenTransfer(context, splTokenMint)
} else {
helper.resolveNativeOrAll(context)
}
val unconsumed = parsed.remainingParams - PARAM_SPL_TOKEN
return helper.validateParams(
result = result,
unconsumedParams = unconsumed,
memo = parsed.memo,
matchingCoins = matchingCoins,
)
}
private companion object {
val BLOCKCHAIN = Blockchain.Solana
val SCHEMES = BLOCKCHAIN.getShareScheme()
const val PARAM_SPL_TOKEN = "spl-token"
}
}

View file

@ -0,0 +1,91 @@
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 blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider,
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 isAddressValid = matchingCoins.any {
blockchainDataProvider.validateAddress(it.network, parsed.address)
}
if (!isAddressValid) {
return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.Unrecognized(qrCode),
)
}
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.remainingParams[PARAM_TOKEN]
val result = if (tokenContractAddress != null) {
helper.resolveTokenTransfer(context, tokenContractAddress, ::interpretAmount)
} else {
helper.resolveNativeOrAll(context, ::interpretAmount)
}
val unconsumed = parsed.remainingParams - PARAM_TOKEN
return helper.validateParams(
result = result,
unconsumedParams = unconsumed,
memo = parsed.memo,
matchingCoins = matchingCoins,
)
}
/**
* 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

@ -47,11 +47,11 @@ internal class DefaultQrScanningEventsRepository(
val result = QrResult(address = parsed.address)
result.amount = parsed.amount
result.memo = parsed.memo
result.memo = parsed.memo?.second
// 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 ->
parsed.remainingParams[QrSentUriParser.PARAM_ADDRESS]?.let { addressValue ->
val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult()
if (tokenCurrency.contractAddress.equals(parsed.address, ignoreCase = true)) {
result.address = addressValue
@ -61,8 +61,8 @@ internal class DefaultQrScanningEventsRepository(
}
// ERC-681: value/uint256 is in the smallest unit, needs conversion
val valueStr = parsed.params[QrSentUriParser.PARAM_VALUE]
?: parsed.params[QrSentUriParser.PARAM_UINT256]
val valueStr = parsed.remainingParams[QrSentUriParser.PARAM_VALUE]
?: parsed.remainingParams[QrSentUriParser.PARAM_UINT256]
if (valueStr != null) {
result.amount = valueStr.parseBigDecimalOrNull()
?.toPlainString()?.toBigDecimalOrNull()

View file

@ -15,7 +15,7 @@ import java.math.BigDecimal
internal class Bip321PaymentUriParserTest {
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
every { getShareSchemes(any()) } returns emptyList()
every { validateAddress(any(), any()) } returns true
}
private val parser = Bip321PaymentUriParser(blockchainDataProvider)
@ -23,8 +23,6 @@ internal class Bip321PaymentUriParserTest {
@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 +38,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 +52,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 +66,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 +82,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 +95,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 +118,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 +142,41 @@ internal class Bip321PaymentUriParserTest {
// endregion
// region Unsupported network
@Test
fun `invalid address returns Unrecognized error`() {
every { blockchainDataProvider.validateAddress(any(), eq("InvalidBtcAddress")) } returns false
val result = parser.parse(
qrCode = "bitcoin:InvalidBtcAddress?amount=0.5",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
@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",
@ -191,17 +215,76 @@ internal class Bip321PaymentUriParserTest {
}
@Test
fun `bitcoin URI with memo param`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
fun `bitcoin URI with memo param returns warning for unsupported memo`() {
val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&memo=TestMemo",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java)
val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning
assertThat(warning.content.memo).isEqualTo("TestMemo")
assertThat(warning.unsupportedParams).containsEntry("memo", "TestMemo")
}
// endregion
// region Unsupported params
@Test
fun `unknown parameter returns SuccessWithWarning`() {
val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5&req-unknownparam=bar",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java)
val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning
assertThat(warning.content.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
assertThat(warning.unsupportedParams).containsEntry("req-unknownparam", "bar")
}
@Test
fun `multiple unknown parameters all reported`() {
val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&foo=1&bar=2",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java)
val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning
assertThat(warning.unsupportedParams).hasSize(2)
assertThat(warning.unsupportedParams).containsEntry("foo", "1")
assertThat(warning.unsupportedParams).containsEntry("bar", "2")
}
@Test
fun `memo on network with memo support is not unsupported`() {
val xrpCoin = buildCoin("XRP", "XRP", "XRP", decimals = 6, extrasType = Network.TransactionExtrasType.DESTINATION_TAG)
val result = parser.parse(
qrCode = "ripple:rAddress?dt=12345",
coins = listOf(xrpCoin),
allCurrencies = listOf(xrpCoin),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.memo).isEqualTo("12345")
}
@Test
fun `only known params returns Success`() {
val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5&label=Satoshi",
coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.memo).isEqualTo("TestMemo")
assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
}
// endregion
@ -209,23 +292,33 @@ internal class Bip321PaymentUriParserTest {
// region Helpers
private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? {
return (this as? PaymentUriParser.ParseResult.Success)?.content
return when (this) {
is PaymentUriParser.ParseResult.Success -> content
is PaymentUriParser.ParseResult.SuccessWithWarning -> content
else -> null
}
}
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,
extrasType: Network.TransactionExtrasType = Network.TransactionExtrasType.NONE,
): 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, extrasType),
name = name,
symbol = symbol,
decimals = decimals,
iconUrl = null,
isCustom = false,
@ -239,7 +332,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,18 +342,23 @@ internal class Bip321PaymentUriParserTest {
)
}
private fun buildNetwork(rawNetworkId: String): Network {
private fun buildNetwork(
rawNetworkId: String,
name: String,
symbol: String,
extrasType: Network.TransactionExtrasType = Network.TransactionExtrasType.NONE,
): 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"),
hasFiatFeeRate = false,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
transactionExtrasType = extrasType,
nameResolvingType = Network.NameResolvingType.NONE,
)
}

View file

@ -15,9 +15,9 @@ 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
every { validateAddress(any(), any()) } returns true
}
private val parser = Eip681PaymentUriParser(blockchainDataProvider)
@ -56,8 +56,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",
@ -174,6 +174,24 @@ internal class Eip681PaymentUriParserTest {
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
@Test
fun `ERC-20 transfer with invalid recipient address returns Unrecognized error`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
every { blockchainDataProvider.validateAddress(any(), eq("0xInvalidRecipient")) } returns false
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
val result = parser.parse(
qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xInvalidRecipient&uint256=1000000",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin, usdcToken),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
// endregion
@Test
@ -265,10 +283,64 @@ internal class Eip681PaymentUriParserTest {
// endregion
// region Unsupported params
@Test
fun `native transfer with unknown param returns SuccessWithWarning`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val result = parser.parse(
qrCode = "ethereum:0xRecipient@1?value=1000000000000000000&gasLimit=21000",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java)
val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning
assertThat(warning.content.address).isEqualTo("0xRecipient")
assertThat(warning.unsupportedParams).containsEntry("gasLimit", "21000")
}
@Test
fun `ERC-20 transfer with unknown param returns SuccessWithWarning`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
val result = parser.parse(
qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xRecipient&uint256=1000000&gasPrice=20000000000",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin, usdcToken),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java)
val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning
assertThat(warning.unsupportedParams).containsEntry("gasPrice", "20000000000")
}
@Test
fun `native transfer with only known params returns Success`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val result = parser.parse(
qrCode = "ethereum:0xRecipient@1?value=1000000000000000000",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.Success::class.java)
}
// endregion
// region Helpers
private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? {
return (this as? PaymentUriParser.ParseResult.Success)?.content
return when (this) {
is PaymentUriParser.ParseResult.Success -> content
is PaymentUriParser.ParseResult.SuccessWithWarning -> content
else -> null
}
}
private val bitcoinCoin = buildCoin("bitcoin", decimals = 8)

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,249 @@
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.QrContentClassifierParser
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 io.mockk.every
import io.mockk.mockk
import org.junit.Test
import java.math.BigDecimal
internal class SolanaPaymentUriParserTest {
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
every { validateAddress(any(), any()) } returns true
}
private val parser = SolanaPaymentUriParser(blockchainDataProvider)
// 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 `invalid address returns Unrecognized error`() {
every { blockchainDataProvider.validateAddress(any(), eq("InvalidSolAddress")) } returns false
val result = parser.parse(
qrCode = "solana:InvalidSolAddress?amount=1",
coins = listOf(solanaCoin),
allCurrencies = listOf(solanaCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
@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 Unsupported params
@Test
fun `unknown parameter returns SuccessWithWarning`() {
val result = parser.parse(
qrCode = "solana:SolAddress?amount=1&reference=abc123",
coins = listOf(solanaCoin),
allCurrencies = listOf(solanaCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java)
val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning
assertThat(warning.content.address).isEqualTo("SolAddress")
assertThat(warning.unsupportedParams).containsEntry("reference", "abc123")
}
@Test
fun `memo on non-memo network returns SuccessWithWarning`() {
val result = parser.parse(
qrCode = "solana:SolAddress?amount=1&memo=hello",
coins = listOf(solanaCoin),
allCurrencies = listOf(solanaCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java)
val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning
assertThat(warning.unsupportedParams).containsEntry("memo", "hello")
}
@Test
fun `only known params returns Success`() {
val result = parser.parse(
qrCode = "solana:SolAddress?amount=1",
coins = listOf(solanaCoin),
allCurrencies = listOf(solanaCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.Success::class.java)
}
// endregion
// region Helpers
private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? {
return when (this) {
is PaymentUriParser.ParseResult.Success -> content
is PaymentUriParser.ParseResult.SuccessWithWarning -> content
else -> null
}
}
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,289 @@
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.QrContentClassifierParser
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 io.mockk.every
import io.mockk.mockk
import org.junit.Test
import java.math.BigDecimal
internal class TronPaymentUriParserTest {
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
every { validateAddress(any(), any()) } returns true
}
private val parser = TronPaymentUriParser(blockchainDataProvider)
// 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 `invalid address returns Unrecognized error`() {
every { blockchainDataProvider.validateAddress(any(), eq("InvalidTronAddress")) } returns false
val result = parser.parse(
qrCode = "tron:InvalidTronAddress?amount=1",
coins = listOf(tronCoin),
allCurrencies = listOf(tronCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
}
@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 Unsupported params
@Test
fun `unknown parameter returns SuccessWithWarning`() {
val result = parser.parse(
qrCode = "tron:TAddress?amount=100&gasLimit=21000",
coins = listOf(tronCoin),
allCurrencies = listOf(tronCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java)
val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning
assertThat(warning.content.address).isEqualTo("TAddress")
assertThat(warning.unsupportedParams).containsEntry("gaslimit", "21000")
}
@Test
fun `memo on non-memo network returns SuccessWithWarning`() {
val result = parser.parse(
qrCode = "tron:TAddress?amount=100&memo=hello",
coins = listOf(tronCoin),
allCurrencies = listOf(tronCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java)
val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning
assertThat(warning.unsupportedParams).containsEntry("memo", "hello")
}
@Test
fun `only known params returns Success`() {
val result = parser.parse(
qrCode = "tron:TAddress?amount=100",
coins = listOf(tronCoin),
allCurrencies = listOf(tronCoin),
)
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.Success::class.java)
}
// endregion
// region Helpers
private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? {
return when (this) {
is PaymentUriParser.ParseResult.Success -> content
is PaymentUriParser.ParseResult.SuccessWithWarning -> content
else -> null
}
}
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
}

View file

@ -284,7 +284,7 @@ class GetAccountCurrencyStatusUseCaseTest {
coEvery { supplier(supplierParams) } returns flowOf(accountStatusList)
// Act
val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null)
val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = currency.network)
.let(::getEmittedValues)
// Assert

View file

@ -30,6 +30,11 @@ interface NetworksRepository {
*/
suspend fun getNetworkAddresses(userWalletId: UserWalletId, network: Network.RawID): List<CryptoCurrencyAddress>
/**
* Returns the default address for the given [network] in the selected [userWalletId]
*/
suspend fun getDefaultAddress(userWalletId: UserWalletId, network: Network): String?
/** Checks if there are cached statuses for given [userWalletId] */
suspend fun hasCachedStatuses(userWalletId: UserWalletId): Boolean
}

View file

@ -7,6 +7,7 @@ import com.tangem.domain.promo.models.StoryContent
import com.tangem.domain.promo.models.StoryContentIds
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import kotlin.time.Duration.Companion.seconds
@ -39,12 +40,14 @@ class GetStoryContentUseCase(
}
}
@OptIn(FlowPreview::class)
private fun isFCAAllowed(id: String): Flow<Boolean> {
return if (id == StoryContentIds.STORY_FIRST_TIME_SWAP.id) {
settingsRepository.getUserCountryCode()
.filterNotNull()
.timeout(5.seconds)
.timeout(3.seconds)
.map { !it.needApplyFCARestrictions() }
.catch { emit(true) }
} else {
flowOf(true)
}

View file

@ -14,6 +14,7 @@ dependencies {
api(projects.domain.models)
implementation(projects.domain.account)
implementation(projects.domain.common)
implementation(projects.domain.networks)
implementation(projects.domain.qrScanning.models)
implementation(projects.domain.tokens.models)

View file

@ -19,6 +19,11 @@ sealed interface ClassifiedQrContent {
val matchingCurrencies: List<CryptoCurrency>,
) : ClassifiedQrContent
data class PaymentUriWarning(
val paymentUri: PaymentUri,
val unsupportedParams: Map<String, String>,
) : ClassifiedQrContent
sealed interface Error : ClassifiedQrContent {
/** QR code not recognized by any parser */

View file

@ -39,6 +39,13 @@ sealed class QrSendTarget {
)
}
data object AddressSameAsWallet : QrSendTarget()
data class Warning(
val target: QrSendTarget,
val unsupportedParams: Map<String, String>,
) : QrSendTarget()
data class WalletConnect(val uri: String) : QrSendTarget()
data class Error(val error: ClassifiedQrContent.Error) : QrSendTarget()

View file

@ -5,17 +5,23 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.networks.repository.NetworksRepository
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import java.math.BigDecimal
import com.tangem.domain.qrscanning.models.QrSendTarget
import kotlinx.coroutines.awaitAll
class ResolveQrSendTargetsUseCase(
private val multiAccountListSupplier: MultiAccountListSupplier,
private val qrScanningEventsRepository: QrScanningEventsRepository,
private val userWalletsListRepository: UserWalletsListRepository,
private val networksRepository: NetworksRepository,
) {
suspend operator fun invoke(qrCode: String): QrSendTarget {
@ -49,7 +55,7 @@ class ResolveQrSendTargetsUseCase(
return resolve(classified, portfolioIndex)
}
private fun resolve(classified: ClassifiedQrContent, portfolioIndex: PortfolioIndex): QrSendTarget {
private suspend fun resolve(classified: ClassifiedQrContent, portfolioIndex: PortfolioIndex): QrSendTarget {
return when (classified) {
is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri)
is ClassifiedQrContent.Error -> QrSendTarget.Error(classified)
@ -67,17 +73,29 @@ class ResolveQrSendTargetsUseCase(
matchingCurrencies = classified.matchingCurrencies,
portfolioIndex = portfolioIndex,
)
is ClassifiedQrContent.PaymentUriWarning -> {
val inner = resolve(classified.paymentUri, portfolioIndex)
QrSendTarget.Warning(
target = inner,
unsupportedParams = classified.unsupportedParams,
)
}
}
}
private fun resolveAddressTarget(
private suspend fun resolveAddressTarget(
address: String,
amount: BigDecimal?,
memo: String?,
matchingCurrencies: List<CryptoCurrency>,
portfolioIndex: PortfolioIndex,
): QrSendTarget {
val walletGroups = buildWalletGroups(matchingCurrencies, portfolioIndex)
val ownAddressNetworks = findOwnAddressNetworks(address, matchingCurrencies, portfolioIndex)
val walletGroups = buildWalletGroups(matchingCurrencies, portfolioIndex, ownAddressNetworks)
if (walletGroups.isEmpty()) {
return QrSendTarget.AddressSameAsWallet
}
val singleGroup = walletGroups.singleOrNull()
val singleCurrency = singleGroup?.accounts?.singleOrNull()?.currencies?.singleOrNull()
@ -100,9 +118,33 @@ class ResolveQrSendTargetsUseCase(
}
}
private suspend fun findOwnAddressNetworks(
address: String,
matchingCurrencies: List<CryptoCurrency>,
portfolioIndex: PortfolioIndex,
): Map<UserWalletId, List<Network.ID>> = coroutineScope {
matchingCurrencies.distinctBy { it.id }
.flatMap { currency ->
portfolioIndex.currencyLocations[currency.id].orEmpty().map {
it.accountId.userWalletId to currency.network
}
}
.distinct()
.map { (walletId, network) ->
async {
val ownAddress = networksRepository.getDefaultAddress(walletId, network)
if (ownAddress == address) walletId to network else null
}
}
.awaitAll()
.filterNotNull()
.groupBy(keySelector = { it.first }, valueTransform = { it.second.id })
}
private fun buildWalletGroups(
matchingCurrencies: List<CryptoCurrency>,
portfolioIndex: PortfolioIndex,
ownAddressNetworks: Map<UserWalletId, List<Network.ID>>,
): List<QrSendTarget.Multiple.WalletGroup> {
val walletMap = linkedMapOf<UserWalletId, WalletInfo>()
val uniqueCurrencies = matchingCurrencies.distinctBy { it.id }
@ -110,13 +152,18 @@ class ResolveQrSendTargetsUseCase(
for (currency in uniqueCurrencies) {
val locations = portfolioIndex.currencyLocations[currency.id].orEmpty()
for (location in locations) {
val walletInfo = walletMap.getOrPut(location.accountId.userWalletId) {
WalletInfo(location.walletName, linkedMapOf())
val walletId = location.accountId.userWalletId
val isOwnAddress = ownAddressNetworks[walletId]?.contains(currency.network.id) == true
if (!isOwnAddress) {
val walletInfo = walletMap.getOrPut(walletId) {
WalletInfo(location.walletName, linkedMapOf())
}
val accountInfo = walletInfo.accounts.getOrPut(location.accountId) {
AccountInfo(location.accountName, mutableListOf())
}
accountInfo.currencies.add(currency)
}
val accountInfo = walletInfo.accounts.getOrPut(location.accountId) {
AccountInfo(location.accountName, mutableListOf())
}
accountInfo.currencies.add(currency)
}
}

View file

@ -83,7 +83,9 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.SwapAlertUM
import com.tangem.feature.swap.models.SwapCardState
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.TransactionCardType
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.router.SwapNavScreen
@ -215,7 +217,7 @@ internal class SwapModel @Inject constructor(
val feeSelectorRepository = FeeSelectorRepository()
// shows currency order (direct - swap initial to selected, reversed = selected to initial)
private var isOrderReversed by mutableStateOf(false)
private val isOrderReversed = MutableStateFlow(value = params.isInitialReverseOrder)
private val lastAmount = mutableStateOf(INITIAL_AMOUNT)
private val lastReducedBalanceBy = mutableStateOf(BigDecimal.ZERO)
private val swapRouter: SwapRouter = SwapRouter(router = router)
@ -427,20 +429,22 @@ internal class SwapModel @Inject constructor(
selectedAccountCurrency?.status to selectedAccountCurrency?.account
}
applyInitialTokenChoice(
val isApplied = applyInitialTokenChoice(
state = state,
selectedCurrency = selectedCurrency,
selectedAccount = selectedAccount,
isReverseFromTo = isReverseFromTo,
)
val fromCryptoCurrency = if (isOrderReversed) {
dataState.toCryptoCurrency
} else {
dataState.fromCryptoCurrency
}
// assume that fromCryptoCurrency selected according reverse flag,
// so update fee paid currency according to it
val fromCryptoCurrency = dataState.fromCryptoCurrency
if (fromCryptoCurrency != null) {
if (isApplied && fromCryptoCurrency != null) {
TangemLogger.i(
"updateFeePaidCryptoCurrencyFor: id = ${fromCryptoCurrency.currency.id}, " +
"isReverseFromTo: $isReverseFromTo",
)
updateFeePaidCryptoCurrencyFor(fromCryptoCurrency)
} else {
TangemLogger.e("updateFeePaidCryptoCurrencyFor failed: fromCryptoCurrency is null")
@ -498,7 +502,7 @@ internal class SwapModel @Inject constructor(
state = state,
selectedCurrency = selectedCurrency,
selectedAccount = selectedAccount,
isReverseFromTo = isOrderReversed,
isReverseFromTo = isOrderReversed.value,
)
subscribeToCoinBalanceUpdatesIfNeeded()
@ -527,29 +531,34 @@ internal class SwapModel @Inject constructor(
}
}
/**
* returns true if tokens are selected and dataState is updated,
* false if selected token is null and alert is shown with error message
*/
private fun applyInitialTokenChoice(
state: TokensDataStateExpress,
selectedCurrency: CryptoCurrencyStatus?,
selectedAccount: Account.CryptoPortfolio?,
isReverseFromTo: Boolean,
) {
): Boolean {
// exceptional case
if (selectedCurrency == null) {
TangemLogger.e("No available tokens to swap for ${initialCurrencyFrom.symbol}")
analyticsEventHandler.send(SwapEvents.NoticeNoAvailableTokensToSwap())
uiState = stateBuilder.createNoAvailableTokensToSwapState(
uiStateHolder = uiState,
fromToken = initialFromStatus,
)
return
return false
}
isOrderReversed = isReverseFromTo
val (fromCurrencyStatus, toCurrencyStatus) = if (isOrderReversed) {
isOrderReversed.value = isReverseFromTo
val (fromCurrencyStatus, toCurrencyStatus) = if (isOrderReversed.value) {
selectedCurrency to initialFromStatus
} else {
initialFromStatus to selectedCurrency
}
val (fromAccount, toAccount) = if (canUseFromAccountCurrencyStatus) {
if (isOrderReversed) {
if (isOrderReversed.value) {
selectedAccount to requireNotNull(fromAccountCurrencyStatus).account
} else {
requireNotNull(fromAccountCurrencyStatus).account to selectedAccount
@ -573,7 +582,7 @@ internal class SwapModel @Inject constructor(
toAccount = toAccount,
)
) {
return
return true
}
startLoadingQuotes(
@ -585,10 +594,11 @@ internal class SwapModel @Inject constructor(
reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(fromCurrencyStatus, toCurrencyStatus),
)
return true
}
private fun updateTokensState(tokenDataState: TokensDataStateExpress) {
val tokensDataState = if (isOrderReversed) tokenDataState.fromGroup else tokenDataState.toGroup
val tokensDataState = if (isOrderReversed.value) tokenDataState.fromGroup else tokenDataState.toGroup
chooseTokenBridge.updateCurrenciesGroup(tokensDataState)
}
@ -1162,7 +1172,7 @@ internal class SwapModel @Inject constructor(
private fun onSearchEntered(searchQuery: String) {
val tokenDataState = dataState.tokensDataState ?: return
val group = if (isOrderReversed) {
val group = if (isOrderReversed.value) {
tokenDataState.fromGroup
} else {
tokenDataState.toGroup
@ -1192,7 +1202,7 @@ internal class SwapModel @Inject constructor(
)
}
val filteredTokenDataState = if (isOrderReversed) {
val filteredTokenDataState = if (isOrderReversed.value) {
tokenDataState.copy(
fromGroup = tokenDataState.fromGroup.copy(
available = available,
@ -1238,7 +1248,7 @@ internal class SwapModel @Inject constructor(
val fromAccount: Account.CryptoPortfolio?
val toToken: CryptoCurrencyStatus
val toAccount: Account.CryptoPortfolio?
if (isOrderReversed) {
if (isOrderReversed.value) {
fromToken = foundToken
fromAccount = foundAccount
toToken = initialFromStatus
@ -1295,6 +1305,17 @@ internal class SwapModel @Inject constructor(
return
}
modelScope.launch {
TangemLogger.i(
"updateFeePaidCryptoCurrencyFor: id = ${fromToken.currency.id}, " +
"isOrderReversed: ${isOrderReversed.value}",
)
if ((uiState.sendCardData as? SwapCardState.SwapCardData)?.type is TransactionCardType.ReadOnly) {
uiState = stateBuilder.createInitialLoadingState(
initialCurrencyFrom = fromToken.currency,
initialCurrencyTo = toToken.currency,
fromNetworkInfo = fromToken.currency.getNetworkInfo(),
)
}
updateFeePaidCryptoCurrencyFor(fromToken)
startLoadingQuotes(
fromToken = fromToken,
@ -1314,7 +1335,7 @@ internal class SwapModel @Inject constructor(
tokens: TokensDataStateExpress,
id: String,
): Pair<CryptoCurrencyStatus?, Account.CryptoPortfolio?> {
val accountCryptoCurrencyStatus = if (isOrderReversed) {
val accountCryptoCurrencyStatus = if (isOrderReversed.value) {
tokens.fromGroup
} else {
tokens.toGroup
@ -1346,7 +1367,23 @@ internal class SwapModel @Inject constructor(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = currencyStatus,
).getOrNull() ?: currencyStatus,
)
.onLeft {
TangemLogger.e(
"Coin balance: Unable to get fee paid crypto currency status for " +
"${currencyStatus.currency.id}",
)
}
.onRight { status ->
if (status == null) {
TangemLogger.e(
"Coin balance: Fee paid crypto currency status is null " +
"for ${currencyStatus.currency.id}",
)
}
}
.getOrNull()
?: currencyStatus,
)
}
@ -1392,7 +1429,11 @@ internal class SwapModel @Inject constructor(
toCryptoCurrency = newToToken,
toAccount = newToAccount,
)
isOrderReversed = !isOrderReversed
isOrderReversed.value = !isOrderReversed.value
TangemLogger.i(
"updateFeePaidCryptoCurrencyFor: id = ${newFromToken.currency.id}, " +
"isOrderReversed: ${isOrderReversed.value}",
)
updateFeePaidCryptoCurrencyFor(newFromToken)
dataState.tokensDataState?.let { tokensDataState ->
updateTokensState(tokensDataState)
@ -1824,13 +1865,13 @@ internal class SwapModel @Inject constructor(
}
private fun findSwapProviders(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): List<SwapProvider> {
val groupToFind = if (isOrderReversed) {
val groupToFind = if (isOrderReversed.value) {
dataState.tokensDataState?.fromGroup
} else {
dataState.tokensDataState?.toGroup
} ?: return emptyList()
val idToFind = if (isOrderReversed) {
val idToFind = if (isOrderReversed.value) {
fromToken.currency.id.value
} else {
toToken.currency.id.value
@ -1856,8 +1897,8 @@ internal class SwapModel @Inject constructor(
fromAccount: Account.CryptoPortfolio?,
toAccount: Account.CryptoPortfolio?,
): Boolean {
val selectedCurrency = if (isOrderReversed) fromToken else toToken
if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed)) return false
val selectedCurrency = if (isOrderReversed.value) fromToken else toToken
if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed.value)) return false
analyticsEventHandler.send(
SwapEvents.NoticeUnavailableToSwapPair(
@ -1867,6 +1908,14 @@ internal class SwapModel @Inject constructor(
receiveBlockchain = toToken.currency.network.name,
),
)
// Cancel periodic quote task if selected token is not supported
singleTaskScheduler.cancelTask()
// Reset data state
dataState = SwapProcessDataState(
tokensDataState = dataState.tokensDataState,
)
lastReducedBalanceBy.value = BigDecimal.ZERO
lastAmount.value = INITIAL_AMOUNT
uiState = stateBuilder.createSwapNotSupportedState(
uiStateHolder = uiState,
fromToken = fromToken,
@ -1917,13 +1966,13 @@ internal class SwapModel @Inject constructor(
val from = dataState.fromCryptoCurrency ?: return false
val to = dataState.toCryptoCurrency ?: return false
val currenciesGroup = if (isOrderReversed) {
val currenciesGroup = if (isOrderReversed.value) {
dataState.tokensDataState?.toGroup
} else {
dataState.tokensDataState?.fromGroup
} ?: return false
val chosen = if (isOrderReversed) from else to
val chosen = if (isOrderReversed.value) from else to
return currenciesGroup.accountCurrencyList.flatMap { accountSwapAvailability ->
accountSwapAvailability.currencyList.map { accountSwapCurrency ->

View file

@ -183,10 +183,12 @@ internal class StateBuilder(
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
return uiStateHolder.copy(
sendCardData = SwapCardState.SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable).copy(
type = TransactionCardType.ReadOnly(
accountTitleUM = getFromCardAccountTitle(fromAccount),
),
amountTextFieldValue = null,
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
token = fromToken,
tokenIconUrl = fromToken.currency.iconUrl,

View file

@ -771,7 +771,12 @@ internal class WalletModel @Inject constructor(
}
private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) {
when (val target = resolveQrSendTargetsUseCase(qrCode)) {
val target = resolveQrSendTargetsUseCase(qrCode)
handleQrTarget(target, resultSource)
}
private fun handleQrTarget(target: QrSendTarget, resultSource: QrResultSource) {
when (target) {
is QrSendTarget.WalletConnect -> {
val source = when (resultSource) {
QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD
@ -801,6 +806,17 @@ internal class WalletModel @Inject constructor(
is QrSendTarget.Multiple -> {
innerWalletRouter.openNetworkSelectionBottomSheet(target)
}
is QrSendTarget.AddressSameAsWallet -> {
uiMessageSender.send(WalletAlertUM.qrCodeAddressSameAsWallet())
}
is QrSendTarget.Warning -> {
uiMessageSender.send(
WalletAlertUM.qrCodeUnsupportedParams(
unsupportedParams = target.unsupportedParams,
onContinue = { handleQrTarget(target.target, resultSource) },
),
)
}
is QrSendTarget.Error -> handleQrError(target.error)
}
}

View file

@ -211,6 +211,7 @@ internal object WalletScreenPreviewData {
isHidingMode = false,
showMarketsOnboarding = false,
onDismissMarketsTooltip = {},
isRedesignEnabled = true,
)
val defaultAccountState = defaultState.copy(

View file

@ -248,6 +248,7 @@ internal object WalletScreenPreviewDataLegacy {
isHidingMode = false,
showMarketsOnboarding = false,
onDismissMarketsTooltip = {},
isRedesignEnabled = false,
)
internal val accountScreenState =

View file

@ -19,6 +19,7 @@ internal class MultiWalletContentLoader @AssistedInject constructor(
private val multiWalletActionButtonsSubscriberFactory: MultiWalletActionButtonsSubscriber.Factory,
private val tangemPayMainSubscriberFactory: TangemPayMainSubscriber.Factory,
private val tokenSyncSubscriberFactory: TokenSyncSubscriber.Factory,
private val tokenListAnalyticsSubscriberFactory: TokenListAnalyticsSubscriber.Factory,
private val designFeatureToggles: DesignFeatureToggles,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) : WalletContentLoader(id = userWallet.walletId) {
@ -34,6 +35,7 @@ internal class MultiWalletContentLoader @AssistedInject constructor(
},
multiWalletActionButtonsSubscriberFactory.create(userWallet),
tangemPayMainSubscriberFactory.create(userWallet),
tokenListAnalyticsSubscriberFactory.create(userWallet),
if (hotWalletFeatureToggles.isTokenSyncEnabled && userWallet is UserWallet.Hot) {
tokenSyncSubscriberFactory.create(userWallet)
} else {

View file

@ -139,6 +139,7 @@ internal class WalletStateController @Inject constructor(
isHidingMode = false,
showMarketsOnboarding = false,
onDismissMarketsTooltip = {},
isRedesignEnabled = designFeatureToggles.isRedesignEnabled,
)
}
}

View file

@ -76,6 +76,30 @@ internal object WalletAlertUM {
)
}
fun qrCodeUnsupportedParams(unsupportedParams: Map<String, String>, onContinue: () -> Unit): DialogMessage {
val paramsList = unsupportedParams.entries.joinToString { "${it.key} = ${it.value}" }
return DialogMessage(
title = resourceReference(R.string.qr_scanner_warning_unknown_parameters_title),
message = resourceReference(
id = R.string.qr_scanner_warning_unknown_parameters_message,
formatArgs = WrappedList(listOf(paramsList)),
),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_continue),
onClick = onContinue,
)
},
secondActionBuilder = { cancelAction() },
)
}
fun qrCodeAddressSameAsWallet(): DialogMessage {
return DialogMessage(
message = resourceReference(R.string.send_error_address_same_as_wallet),
)
}
fun confirmExpressStatusHide(onConfirmClick: () -> Unit): DialogMessage {
return DialogMessage(
title = resourceReference(R.string.express_status_hide_dialog_title),

View file

@ -15,4 +15,5 @@ internal data class WalletScreenState(
val isHidingMode: Boolean,
val showMarketsOnboarding: Boolean,
val onDismissMarketsTooltip: () -> Unit,
val isRedesignEnabled: Boolean,
)

View file

@ -15,17 +15,22 @@ internal abstract class WalletStateTransformer(
abstract fun transform(walletUM: WalletUM): WalletUM
final override fun transform(prevState: WalletScreenState): WalletScreenState {
return prevState.copy(
wallets = prevState.wallets
.map { state ->
if (state.walletCardState.id == userWalletId) transform(state) else state
}
.toImmutableList(),
wallets2 = prevState.wallets2
.map { walletUM ->
if (walletUM.walletsBalanceUM.id == userWalletId) transform(walletUM) else walletUM
}
.toImmutableList(),
)
return if (prevState.isRedesignEnabled) {
prevState.copy(
wallets2 = prevState.wallets2
.map { walletUM ->
if (walletUM.walletsBalanceUM.id == userWalletId) transform(walletUM) else walletUM
}
.toImmutableList(),
)
} else {
prevState.copy(
wallets = prevState.wallets
.map { state ->
if (state.walletCardState.id == userWalletId) transform(state) else state
}
.toImmutableList(),
)
}
}
}

View file

@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.testTag
@ -107,6 +108,7 @@ internal fun WalletTopBar(
*
* @param config component config
*/
@Suppress("MagicNumber")
@Deprecated("Remove with main toggle [DesignFeatureToggles.isRedesignEnabled]")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -122,7 +124,9 @@ internal fun WalletTopBar(config: WalletTopBarConfig) {
Icon(
painter = painterResource(id = action.iconRes),
contentDescription = null,
modifier = Modifier.testTag(MainScreenTestTags.MORE_BUTTON),
modifier = Modifier
.rotate(if (action.iconRes == R.drawable.ic_more_default_24) 90f else 0f)
.testTag(MainScreenTestTags.MORE_BUTTON),
)
}
}

View file

@ -5,13 +5,13 @@
# https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico
tangemBlockchainSdk = "develop-1461"
tangemBlockchainSdk = "develop-1473"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-598"
tangemCardSdk = "develop-602"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
tangemVico = "tangem-master-21"
#tangemVico = "0.0.1" # Keep it! - used for local builds ^
tangemHotSdk = "develop-549"
tangemHotSdk = "develop-550"
#tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^