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.account.supplier.MultiAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository 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.repository.QrScanningEventsRepository
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
@ -41,11 +42,13 @@ internal object QrScanningDomainModule {
multiAccountListSupplier: MultiAccountListSupplier, multiAccountListSupplier: MultiAccountListSupplier,
qrScanningEventsRepository: QrScanningEventsRepository, qrScanningEventsRepository: QrScanningEventsRepository,
userWalletsListRepository: UserWalletsListRepository, userWalletsListRepository: UserWalletsListRepository,
networksRepository: NetworksRepository,
): ResolveQrSendTargetsUseCase { ): ResolveQrSendTargetsUseCase {
return ResolveQrSendTargetsUseCase( return ResolveQrSendTargetsUseCase(
multiAccountListSupplier = multiAccountListSupplier, multiAccountListSupplier = multiAccountListSupplier,
qrScanningEventsRepository = qrScanningEventsRepository, qrScanningEventsRepository = qrScanningEventsRepository,
userWalletsListRepository = userWalletsListRepository, userWalletsListRepository = userWalletsListRepository,
networksRepository = networksRepository,
) )
} }
} }

View file

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

View file

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

View file

@ -125,7 +125,7 @@ fun Transaction(state: TransactionState, isBalanceHidden: Boolean, modifier: Mod
top.linkTo(parent.top) top.linkTo(parent.top)
bottom.linkTo(timestampItem.top) bottom.linkTo(timestampItem.top)
end.linkTo(parent.end) 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, style = TangemTheme.typography.body2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
} }
is TransactionState.Loading -> { is TransactionState.Loading -> {

View file

@ -57,7 +57,7 @@ internal class DefaultNetworksRepository(
.map { currency -> .map { currency ->
CryptoCurrencyAddress( CryptoCurrencyAddress(
cryptoCurrency = currency, cryptoCurrency = currency,
address = getDefaultAddress(userWalletId, network), address = getDefaultAddress(userWalletId, network).orEmpty(),
) )
} }
} }
@ -74,11 +74,17 @@ internal class DefaultNetworksRepository(
.map { currency -> .map { currency ->
CryptoCurrencyAddress( CryptoCurrencyAddress(
cryptoCurrency = currency, 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 { override suspend fun hasCachedStatuses(userWalletId: UserWalletId): Boolean {
return networksStatusesStore.contains(userWalletId) return networksStatusesStore.contains(userWalletId)
} }
@ -100,10 +106,4 @@ internal class DefaultNetworksRepository(
networksStatusesStore.storeStatus(userWalletId = userWalletId, status = networkStatus) 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.Bip321PaymentUriParser
import com.tangem.data.qrscanning.parser.Eip681PaymentUriParser import com.tangem.data.qrscanning.parser.Eip681PaymentUriParser
import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.data.qrscanning.parser.QrContentClassifierParser
import com.tangem.data.qrscanning.parser.SolanaPaymentUriParser
import com.tangem.data.qrscanning.parser.TronPaymentUriParser
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import dagger.Module import dagger.Module
@ -24,6 +26,8 @@ internal object QrScanningDataModule {
blockchainDataProvider = blockchainDataProvider, blockchainDataProvider = blockchainDataProvider,
paymentUriParsers = setOf( paymentUriParsers = setOf(
Eip681PaymentUriParser(blockchainDataProvider), Eip681PaymentUriParser(blockchainDataProvider),
TronPaymentUriParser(blockchainDataProvider),
SolanaPaymentUriParser(blockchainDataProvider),
Bip321PaymentUriParser(blockchainDataProvider), Bip321PaymentUriParser(blockchainDataProvider),
), ),
), ),

View file

@ -1,10 +1,13 @@
package com.tangem.data.qrscanning.parser package com.tangem.data.qrscanning.parser
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.domain.qrscanning.models.ClassifiedQrContent
internal class Bip321PaymentUriParser( internal class Bip321PaymentUriParser(
private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider, private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider,
private val helper: PaymentUriResolveHelper = PaymentUriResolveHelper(),
) : PaymentUriParser { ) : PaymentUriParser {
override fun parse( override fun parse(
@ -12,52 +15,69 @@ internal class Bip321PaymentUriParser(
coins: List<CryptoCurrency.Coin>, coins: List<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>, allCurrencies: List<CryptoCurrency>,
): PaymentUriParser.ParseResult { ): PaymentUriParser.ParseResult {
val schemeAndRest = extractSchemeAndRest(qrCode, coins) val (scheme, blockchains) = SCHEME_TO_BLOCKCHAINS.entries
.firstOrNull { (scheme, _) -> qrCode.startsWith(scheme, ignoreCase = true) }
?: return PaymentUriParser.ParseResult.NotRecognized ?: return PaymentUriParser.ParseResult.NotRecognized
val (matchingCoins, withoutScheme) = schemeAndRest
val parsed = QrSentUriParser().parse(withoutScheme) val parsed = helper.parseUri(qrCode, scheme)
?: return PaymentUriParser.ParseResult.RecognizedError( ?: return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.Unrecognized(qrCode), ClassifiedQrContent.Error.Unrecognized(qrCode),
) )
val matchingCoins = coins.filter { it.network.toBlockchain() in blockchains }
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
val matchingCurrencies = allCurrencies.filter { it.network.id in matchingNetworkIds } val matchingCurrencies = allCurrencies.filter { it.network.id in matchingNetworkIds }
if (matchingCurrencies.isEmpty()) { if (matchingCurrencies.isEmpty()) {
return PaymentUriParser.ParseResult.RecognizedError( return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.UnsupportedNetwork( ClassifiedQrContent.Error.UnsupportedNetwork(
raw = qrCode, raw = qrCode,
blockchain = matchingCoins.firstOrNull()?.network?.name, blockchain = blockchains.first().fullName,
), ),
) )
} }
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( ClassifiedQrContent.PaymentUri(
address = parsed.address, address = parsed.address,
amount = parsed.amount, amount = parsed.amount,
memo = parsed.memo, memo = parsed.memo?.second,
matchingCurrencies = matchingCurrencies, matchingCurrencies = matchingCurrencies,
), ),
) )
val unconsumed = parsed.remainingParams - PARAM_LABEL
return helper.validateParams(
result = result,
unconsumedParams = unconsumed,
memo = parsed.memo,
matchingCoins = matchingCoins,
)
} }
private fun extractSchemeAndRest( private companion object {
qrCode: String, const val PARAM_LABEL = "label"
coins: List<CryptoCurrency.Coin>, val BLOCKCHAINS = listOf(
): Pair<List<CryptoCurrency.Coin>, String>? { Blockchain.Bitcoin,
for (coin in coins) { Blockchain.BitcoinTestnet,
val schemes = blockchainDataProvider.getShareSchemes(coin.network) Blockchain.Litecoin,
for (scheme in schemes) { Blockchain.Binance,
if (qrCode.startsWith(scheme, ignoreCase = true)) { Blockchain.BinanceTestnet,
val withoutScheme = qrCode.removeRange(0, scheme.length) Blockchain.Dogecoin,
val allMatchingCoins = coins.filter { c -> Blockchain.XRP,
blockchainDataProvider.getShareSchemes(c.network).any { it.equals(scheme, ignoreCase = true) } )
} val SCHEME_TO_BLOCKCHAINS: Map<String, Set<Blockchain>> = BLOCKCHAINS
return allMatchingCoins to withoutScheme .flatMap { blockchain -> blockchain.getShareScheme().map { scheme -> scheme to blockchain } }
} .groupBy({ it.first }, { it.second })
} .mapValues { it.value.toSet() }
}
return null
} }
} }

View file

@ -1,5 +1,6 @@
package com.tangem.data.qrscanning.parser package com.tangem.data.qrscanning.parser
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import java.math.BigDecimal import java.math.BigDecimal
@ -14,11 +15,10 @@ internal class Eip681PaymentUriParser(
coins: List<CryptoCurrency.Coin>, coins: List<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>, allCurrencies: List<CryptoCurrency>,
): PaymentUriParser.ParseResult { ): PaymentUriParser.ParseResult {
if (!qrCode.startsWith(SCHEME)) { val scheme = SCHEMES.find { qrCode.startsWith(it, ignoreCase = true) }
return PaymentUriParser.ParseResult.NotRecognized ?: return PaymentUriParser.ParseResult.NotRecognized
}
val withoutScheme = qrCode.removePrefix(SCHEME) val withoutScheme = qrCode.removePrefix(scheme)
val parsed = parseEip681(withoutScheme) ?: return PaymentUriParser.ParseResult.NotRecognized val parsed = parseEip681(withoutScheme) ?: return PaymentUriParser.ParseResult.NotRecognized
val matchingCoins = findMatchingCoins(parsed.chainId, coins) val matchingCoins = findMatchingCoins(parsed.chainId, coins)
@ -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) { 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( return PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.Unrecognized(qrCode), ClassifiedQrContent.Error.Unrecognized(qrCode),
) )
@ -41,16 +59,30 @@ internal class Eip681PaymentUriParser(
} else { } else {
resolveNativeTransfer(parsed, matchingCoins, allCurrencies) resolveNativeTransfer(parsed, matchingCoins, allCurrencies)
} }
return if (result != null) { if (result == null) {
PaymentUriParser.ParseResult.Success(result) return PaymentUriParser.ParseResult.RecognizedError(
} else {
PaymentUriParser.ParseResult.RecognizedError(
ClassifiedQrContent.Error.UnsupportedNetwork( ClassifiedQrContent.Error.UnsupportedNetwork(
raw = qrCode, raw = qrCode,
blockchain = matchingCoins.firstOrNull()?.network?.name, 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( private fun resolveNativeTransfer(
@ -58,7 +90,7 @@ internal class Eip681PaymentUriParser(
matchingCoins: List<CryptoCurrency.Coin>, matchingCoins: List<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>, allCurrencies: List<CryptoCurrency>,
): ClassifiedQrContent.PaymentUri? { ): ClassifiedQrContent.PaymentUri? {
val valueWei = parsed.params[PARAM_VALUE]?.toBigDecimalOrNull() val valueWei = parsed.params[Param.VALUE.key]?.toBigDecimalOrNull()
if (matchingCoins.isEmpty()) return null if (matchingCoins.isEmpty()) return null
@ -87,7 +119,7 @@ internal class Eip681PaymentUriParser(
matchingCoins: List<CryptoCurrency.Coin>, matchingCoins: List<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>, allCurrencies: List<CryptoCurrency>,
): ClassifiedQrContent.PaymentUri? { ): ClassifiedQrContent.PaymentUri? {
val recipient = parsed.params[PARAM_ADDRESS] ?: return null val recipient = parsed.params[Param.ADDRESS.key] ?: return null
val contractAddress = parsed.targetAddress val contractAddress = parsed.targetAddress
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet() val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
@ -100,7 +132,7 @@ internal class Eip681PaymentUriParser(
if (matchingTokens.isEmpty()) return null 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) val amount = rawAmount?.fromSmallestUnit(matchingTokens.first().decimals)
return ClassifiedQrContent.PaymentUri( return ClassifiedQrContent.PaymentUri(
@ -114,7 +146,7 @@ internal class Eip681PaymentUriParser(
private fun findMatchingCoins(chainId: Long?, coins: List<CryptoCurrency.Coin>): List<CryptoCurrency.Coin> { private fun findMatchingCoins(chainId: Long?, coins: List<CryptoCurrency.Coin>): List<CryptoCurrency.Coin> {
if (chainId == null) { if (chainId == null) {
return coins.filter { coin -> return coins.filter { coin ->
blockchainDataProvider.getShareSchemes(coin.network).any { it.startsWith(SCHEME) } blockchainDataProvider.getChainId(coin.network) != null
} }
} }
return coins.filter { coin -> return coins.filter { coin ->
@ -131,7 +163,7 @@ internal class Eip681PaymentUriParser(
val queryString = match.groupValues[GROUP_QUERY] val queryString = match.groupValues[GROUP_QUERY]
val params = parseQueryParams(queryString) val params = parseQueryParams(queryString)
val chainId = pathChainId ?: params[PARAM_CHAIN_ID]?.toLongOrNull() val chainId = pathChainId ?: params[Param.CHAIN_ID.key]?.toLongOrNull()
return Eip681Result( return Eip681Result(
targetAddress = targetAddress, targetAddress = targetAddress,
@ -161,15 +193,27 @@ internal class Eip681PaymentUriParser(
val params: Map<String, String>, 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 { private companion object {
// ethereum:<address>[@<chainId>][/<function>][?<params>] // ethereum:<address>[@<chainId>][/<function>][?<params>]
val URI_REGEX = Regex("""^([^@/?]+)(?:@(\d+))?(?:/([^?]+))?(?:\?(.+))?$""") val URI_REGEX = Regex("""^([^@/?]+)(?:@(\d+))?(?:/([^?]+))?(?:\?(.+))?$""")
const val SCHEME = "ethereum:" val SCHEMES = Blockchain.Ethereum.getShareScheme()
const val FUNCTION_TRANSFER = "transfer" const val FUNCTION_TRANSFER = "transfer"
const val PARAM_VALUE = "value"
const val PARAM_ADDRESS = "address"
const val PARAM_UINT256 = "uint256"
const val PARAM_CHAIN_ID = "chainId"
const val GROUP_ADDRESS = 1 const val GROUP_ADDRESS = 1
const val GROUP_CHAIN_ID = 2 const val GROUP_CHAIN_ID = 2
const val GROUP_FUNCTION = 3 const val GROUP_FUNCTION = 3

View file

@ -16,5 +16,11 @@ internal interface PaymentUriParser {
/** Successfully parsed with matching currencies. */ /** Successfully parsed with matching currencies. */
data class Success(val content: ClassifiedQrContent.PaymentUri) : ParseResult() 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)) { when (val paymentUriResult = tryParsePaymentUri(qrCode, uniqueCoins, userCurrencies)) {
is PaymentUriParser.ParseResult.Success -> return paymentUriResult.content 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.RecognizedError -> return paymentUriResult.error
is PaymentUriParser.ParseResult.NotRecognized -> Unit is PaymentUriParser.ParseResult.NotRecognized -> Unit
} }
@ -77,7 +83,6 @@ internal class QrContentClassifierParser(
} }
internal interface BlockchainDataProvider { internal interface BlockchainDataProvider {
fun getShareSchemes(network: Network): List<String>
fun validateAddress(network: Network, address: String): Boolean fun validateAddress(network: Network, address: String): Boolean
fun getChainId(network: Network): Long? fun getChainId(network: Network): Long?
fun findSupportedBlockchainName(address: String): String? fun findSupportedBlockchainName(address: String): String?
@ -85,10 +90,6 @@ internal class QrContentClassifierParser(
} }
internal class DefaultBlockchainDataProvider : BlockchainDataProvider { internal class DefaultBlockchainDataProvider : BlockchainDataProvider {
override fun getShareSchemes(network: Network): List<String> {
return runCatching { network.toBlockchain().getShareScheme() }.getOrDefault(emptyList())
}
override fun validateAddress(network: Network, address: String): Boolean { override fun validateAddress(network: Network, address: String): Boolean {
return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false) return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false)
} }

View file

@ -8,8 +8,8 @@ internal class QrSentUriParser {
data class Result( data class Result(
val address: String, val address: String,
val amount: BigDecimal?, val amount: BigDecimal?,
val memo: String?, val memo: Pair<String, String>?,
val params: Map<String, String>, val remainingParams: Map<String, String>,
) )
fun parse(withoutScheme: String): Result? { fun parse(withoutScheme: String): Result? {
@ -20,15 +20,24 @@ internal class QrSentUriParser {
val params = extractParameters(withoutScheme) val params = extractParameters(withoutScheme)
val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull() val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull()
val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let {
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( return Result(
address = address, address = address,
amount = amount, amount = amount,
memo = memo, memo = memo,
params = params, remainingParams = params - consumedKeys,
) )
} }
@ -44,6 +53,17 @@ internal class QrSentUriParser {
.toMap() .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 { companion object {
const val CHAIN_DELIMITER = '@' const val CHAIN_DELIMITER = '@'
const val FUNCTION_DELIMITER = '/' const val FUNCTION_DELIMITER = '/'
@ -51,8 +71,6 @@ internal class QrSentUriParser {
const val PARAMS_DELIMITER = '&' const val PARAMS_DELIMITER = '&'
const val PARAM_VALUE_DELIMITER = '=' const val PARAM_VALUE_DELIMITER = '='
const val PARAM_AMOUNT = "amount" const val PARAM_AMOUNT = "amount"
const val PARAM_MEMO = "memo"
const val PARAM_MESSAGE = "message"
const val PARAM_ADDRESS = "address" const val PARAM_ADDRESS = "address"
const val PARAM_VALUE = "value" const val PARAM_VALUE = "value"
const val PARAM_UINT256 = "uint256" const val PARAM_UINT256 = "uint256"

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

View file

@ -15,7 +15,7 @@ import java.math.BigDecimal
internal class Bip321PaymentUriParserTest { internal class Bip321PaymentUriParserTest {
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> { private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
every { getShareSchemes(any()) } returns emptyList() every { validateAddress(any(), any()) } returns true
} }
private val parser = Bip321PaymentUriParser(blockchainDataProvider) private val parser = Bip321PaymentUriParser(blockchainDataProvider)
@ -23,8 +23,6 @@ internal class Bip321PaymentUriParserTest {
@Test @Test
fun `bitcoin URI with address and amount`() { fun `bitcoin URI with address and amount`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val result = parser.parse( val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5", qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5",
coins = listOf(bitcoinCoin), coins = listOf(bitcoinCoin),
@ -40,8 +38,6 @@ internal class Bip321PaymentUriParserTest {
@Test @Test
fun `bitcoin URI with address only`() { fun `bitcoin URI with address only`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val result = parser.parse( val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
coins = listOf(bitcoinCoin), coins = listOf(bitcoinCoin),
@ -56,8 +52,6 @@ internal class Bip321PaymentUriParserTest {
@Test @Test
fun `bitcoin URI with amount and message`() { fun `bitcoin URI with amount and message`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val result = parser.parse( val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.23&message=Donation", qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.23&message=Donation",
coins = listOf(bitcoinCoin), coins = listOf(bitcoinCoin),
@ -72,8 +66,6 @@ internal class Bip321PaymentUriParserTest {
@Test @Test
fun `bitcoin URI with label and message`() { fun `bitcoin URI with label and message`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val result = parser.parse( val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?label=Satoshi&message=Payment", qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?label=Satoshi&message=Payment",
coins = listOf(bitcoinCoin), coins = listOf(bitcoinCoin),
@ -90,8 +82,6 @@ internal class Bip321PaymentUriParserTest {
@Test @Test
fun `litecoin URI matches litecoin coin`() { fun `litecoin URI matches litecoin coin`() {
every { blockchainDataProvider.getShareSchemes(litecoinCoin.network) } returns listOf("litecoin:")
val result = parser.parse( val result = parser.parse(
qrCode = "litecoin:LcHKx4Tt97hnGgR3CRUiB1gSQ3F8wMozLj?amount=10", qrCode = "litecoin:LcHKx4Tt97hnGgR3CRUiB1gSQ3F8wMozLj?amount=10",
coins = listOf(litecoinCoin), coins = listOf(litecoinCoin),
@ -105,11 +95,21 @@ internal class Bip321PaymentUriParserTest {
} }
@Test @Test
fun `no matching scheme returns NotRecognized`() { fun `dogecoin URI matches dogecoin coin`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val result = parser.parse( val result = parser.parse(
qrCode = "dogecoin:DAddress?amount=100", qrCode = "doge:DAddress?amount=100",
coins = listOf(dogecoinCoin),
allCurrencies = listOf(dogecoinCoin),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.address).isEqualTo("DAddress")
}
@Test
fun `no matching scheme returns NotRecognized`() {
val result = parser.parse(
qrCode = "solana:SomeAddress?amount=100",
coins = listOf(bitcoinCoin), coins = listOf(bitcoinCoin),
allCurrencies = listOf(bitcoinCoin), allCurrencies = listOf(bitcoinCoin),
) )
@ -118,22 +118,18 @@ internal class Bip321PaymentUriParserTest {
} }
@Test @Test
fun `ethereum scheme matches as Success`() { fun `ethereum scheme returns NotRecognized`() {
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:")
val result = parser.parse( val result = parser.parse(
qrCode = "ethereum:0xRecipient?value=1000", qrCode = "ethereum:0xRecipient?value=1000",
coins = listOf(ethereumCoin), coins = listOf(bitcoinCoin),
allCurrencies = listOf(ethereumCoin), allCurrencies = listOf(bitcoinCoin),
).asSuccess() )
assertThat(result).isNotNull() assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
} }
@Test @Test
fun `case insensitive scheme matching`() { fun `case insensitive scheme matching`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val result = parser.parse( val result = parser.parse(
qrCode = "Bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1", qrCode = "Bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1",
coins = listOf(bitcoinCoin), coins = listOf(bitcoinCoin),
@ -146,13 +142,41 @@ internal class Bip321PaymentUriParserTest {
// endregion // 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 // region Includes tokens on matching network
@Test @Test
fun `includes tokens on matching network`() { fun `includes tokens on matching network`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") val btcToken = buildToken("BTC", "RUNE", "contractAddr")
val btcToken = buildToken("bitcoin", "RUNE", "contractAddr")
val result = parser.parse( val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.01", qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.01",
@ -191,17 +215,76 @@ internal class Bip321PaymentUriParserTest {
} }
@Test @Test
fun `bitcoin URI with memo param`() { fun `bitcoin URI with memo param returns warning for unsupported memo`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val result = parser.parse( val result = parser.parse(
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&memo=TestMemo", qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&memo=TestMemo",
coins = listOf(bitcoinCoin), coins = listOf(bitcoinCoin),
allCurrencies = 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() ).asSuccess()
assertThat(result).isNotNull() assertThat(result).isNotNull()
assertThat(result!!.memo).isEqualTo("TestMemo") assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
} }
// endregion // endregion
@ -209,23 +292,33 @@ internal class Bip321PaymentUriParserTest {
// region Helpers // region Helpers
private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? { 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 bitcoinCoin = buildCoin("BTC", "Bitcoin", "BTC", decimals = 8)
private val litecoinCoin = buildCoin("litecoin", decimals = 8) private val litecoinCoin = buildCoin("LTC", "Litecoin", "LTC", decimals = 8)
private val ethereumCoin = buildCoin("ethereum", decimals = 18) private val dogecoinCoin = buildCoin("DOGE", "Dogecoin", "DOGE", decimals = 8)
private fun buildCoin(rawNetworkId: String, decimals: Int): CryptoCurrency.Coin { private fun buildCoin(
rawNetworkId: String,
name: String,
symbol: String,
decimals: Int,
extrasType: Network.TransactionExtrasType = Network.TransactionExtrasType.NONE,
): CryptoCurrency.Coin {
return CryptoCurrency.Coin( return CryptoCurrency.Coin(
id = CryptoCurrency.ID( id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId), suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
), ),
network = buildNetwork(rawNetworkId), network = buildNetwork(rawNetworkId, name, symbol, extrasType),
name = rawNetworkId, name = name,
symbol = rawNetworkId.take(3).uppercase(), symbol = symbol,
decimals = decimals, decimals = decimals,
iconUrl = null, iconUrl = null,
isCustom = false, isCustom = false,
@ -239,7 +332,7 @@ internal class Bip321PaymentUriParserTest {
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress), suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress),
), ),
network = buildNetwork(rawNetworkId), network = buildNetwork(rawNetworkId, rawNetworkId, symbol),
name = symbol, name = symbol,
symbol = symbol, symbol = symbol,
decimals = 6, decimals = 6,
@ -249,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( return Network(
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
backendId = rawNetworkId, backendId = rawNetworkId,
name = rawNetworkId, name = name,
currencySymbol = rawNetworkId.take(3).uppercase(), currencySymbol = symbol,
derivationPath = Network.DerivationPath.None, derivationPath = Network.DerivationPath.None,
isTestnet = false, isTestnet = false,
standardType = Network.StandardType.Unspecified("UNSPECIFIED"), standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
hasFiatFeeRate = false, hasFiatFeeRate = false,
canHandleTokens = false, canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE, transactionExtrasType = extrasType,
nameResolvingType = Network.NameResolvingType.NONE, nameResolvingType = Network.NameResolvingType.NONE,
) )
} }

View file

@ -15,9 +15,9 @@ import java.math.BigDecimal
internal class Eip681PaymentUriParserTest { internal class Eip681PaymentUriParserTest {
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> { private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
every { getShareSchemes(any()) } returns emptyList()
every { getChainId(any()) } returns null every { getChainId(any()) } returns null
every { getBlockchainNameByChainId(any()) } returns null every { getBlockchainNameByChainId(any()) } returns null
every { validateAddress(any(), any()) } returns true
} }
private val parser = Eip681PaymentUriParser(blockchainDataProvider) private val parser = Eip681PaymentUriParser(blockchainDataProvider)
@ -56,8 +56,8 @@ internal class Eip681PaymentUriParserTest {
} }
@Test @Test
fun `native transfer without chain_id falls back to scheme matching`() { fun `native transfer without chain_id falls back to chainId presence check`() {
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:") every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val result = parser.parse( val result = parser.parse(
qrCode = "ethereum:0xRecipient?value=1000000000000000000", qrCode = "ethereum:0xRecipient?value=1000000000000000000",
@ -174,6 +174,24 @@ internal class Eip681PaymentUriParserTest {
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java) 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 // endregion
@Test @Test
@ -265,10 +283,64 @@ internal class Eip681PaymentUriParserTest {
// endregion // 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 // region Helpers
private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? { 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 bitcoinCoin = buildCoin("bitcoin", decimals = 8)

View file

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

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) coEvery { supplier(supplierParams) } returns flowOf(accountStatusList)
// Act // Act
val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null) val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = currency.network)
.let(::getEmittedValues) .let(::getEmittedValues)
// Assert // Assert

View file

@ -30,6 +30,11 @@ interface NetworksRepository {
*/ */
suspend fun getNetworkAddresses(userWalletId: UserWalletId, network: Network.RawID): List<CryptoCurrencyAddress> 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] */ /** Checks if there are cached statuses for given [userWalletId] */
suspend fun hasCachedStatuses(userWalletId: UserWalletId): Boolean 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.promo.models.StoryContentIds
import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -39,12 +40,14 @@ class GetStoryContentUseCase(
} }
} }
@OptIn(FlowPreview::class)
private fun isFCAAllowed(id: String): Flow<Boolean> { private fun isFCAAllowed(id: String): Flow<Boolean> {
return if (id == StoryContentIds.STORY_FIRST_TIME_SWAP.id) { return if (id == StoryContentIds.STORY_FIRST_TIME_SWAP.id) {
settingsRepository.getUserCountryCode() settingsRepository.getUserCountryCode()
.filterNotNull() .filterNotNull()
.timeout(5.seconds) .timeout(3.seconds)
.map { !it.needApplyFCARestrictions() } .map { !it.needApplyFCARestrictions() }
.catch { emit(true) }
} else { } else {
flowOf(true) flowOf(true)
} }

View file

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

View file

@ -19,6 +19,11 @@ sealed interface ClassifiedQrContent {
val matchingCurrencies: List<CryptoCurrency>, val matchingCurrencies: List<CryptoCurrency>,
) : ClassifiedQrContent ) : ClassifiedQrContent
data class PaymentUriWarning(
val paymentUri: PaymentUri,
val unsupportedParams: Map<String, String>,
) : ClassifiedQrContent
sealed interface Error : ClassifiedQrContent { sealed interface Error : ClassifiedQrContent {
/** QR code not recognized by any parser */ /** 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 WalletConnect(val uri: String) : QrSendTarget()
data class Error(val error: ClassifiedQrContent.Error) : 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.AccountId
import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.common.wallets.UserWalletsListRepository 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.models.ClassifiedQrContent
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import java.math.BigDecimal import java.math.BigDecimal
import com.tangem.domain.qrscanning.models.QrSendTarget import com.tangem.domain.qrscanning.models.QrSendTarget
import kotlinx.coroutines.awaitAll
class ResolveQrSendTargetsUseCase( class ResolveQrSendTargetsUseCase(
private val multiAccountListSupplier: MultiAccountListSupplier, private val multiAccountListSupplier: MultiAccountListSupplier,
private val qrScanningEventsRepository: QrScanningEventsRepository, private val qrScanningEventsRepository: QrScanningEventsRepository,
private val userWalletsListRepository: UserWalletsListRepository, private val userWalletsListRepository: UserWalletsListRepository,
private val networksRepository: NetworksRepository,
) { ) {
suspend operator fun invoke(qrCode: String): QrSendTarget { suspend operator fun invoke(qrCode: String): QrSendTarget {
@ -49,7 +55,7 @@ class ResolveQrSendTargetsUseCase(
return resolve(classified, portfolioIndex) return resolve(classified, portfolioIndex)
} }
private fun resolve(classified: ClassifiedQrContent, portfolioIndex: PortfolioIndex): QrSendTarget { private suspend fun resolve(classified: ClassifiedQrContent, portfolioIndex: PortfolioIndex): QrSendTarget {
return when (classified) { return when (classified) {
is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri) is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri)
is ClassifiedQrContent.Error -> QrSendTarget.Error(classified) is ClassifiedQrContent.Error -> QrSendTarget.Error(classified)
@ -67,17 +73,29 @@ class ResolveQrSendTargetsUseCase(
matchingCurrencies = classified.matchingCurrencies, matchingCurrencies = classified.matchingCurrencies,
portfolioIndex = portfolioIndex, 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, address: String,
amount: BigDecimal?, amount: BigDecimal?,
memo: String?, memo: String?,
matchingCurrencies: List<CryptoCurrency>, matchingCurrencies: List<CryptoCurrency>,
portfolioIndex: PortfolioIndex, portfolioIndex: PortfolioIndex,
): QrSendTarget { ): 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 singleGroup = walletGroups.singleOrNull()
val singleCurrency = singleGroup?.accounts?.singleOrNull()?.currencies?.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( private fun buildWalletGroups(
matchingCurrencies: List<CryptoCurrency>, matchingCurrencies: List<CryptoCurrency>,
portfolioIndex: PortfolioIndex, portfolioIndex: PortfolioIndex,
ownAddressNetworks: Map<UserWalletId, List<Network.ID>>,
): List<QrSendTarget.Multiple.WalletGroup> { ): List<QrSendTarget.Multiple.WalletGroup> {
val walletMap = linkedMapOf<UserWalletId, WalletInfo>() val walletMap = linkedMapOf<UserWalletId, WalletInfo>()
val uniqueCurrencies = matchingCurrencies.distinctBy { it.id } val uniqueCurrencies = matchingCurrencies.distinctBy { it.id }
@ -110,13 +152,18 @@ class ResolveQrSendTargetsUseCase(
for (currency in uniqueCurrencies) { for (currency in uniqueCurrencies) {
val locations = portfolioIndex.currencyLocations[currency.id].orEmpty() val locations = portfolioIndex.currencyLocations[currency.id].orEmpty()
for (location in locations) { for (location in locations) {
val walletInfo = walletMap.getOrPut(location.accountId.userWalletId) { val walletId = location.accountId.userWalletId
WalletInfo(location.walletName, linkedMapOf()) 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.domain.*
import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.SwapAlertUM 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.SwapStateHolder
import com.tangem.feature.swap.models.TransactionCardType
import com.tangem.feature.swap.models.UiActions import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.router.SwapNavScreen import com.tangem.feature.swap.router.SwapNavScreen
@ -215,7 +217,7 @@ internal class SwapModel @Inject constructor(
val feeSelectorRepository = FeeSelectorRepository() val feeSelectorRepository = FeeSelectorRepository()
// shows currency order (direct - swap initial to selected, reversed = selected to initial) // 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 lastAmount = mutableStateOf(INITIAL_AMOUNT)
private val lastReducedBalanceBy = mutableStateOf(BigDecimal.ZERO) private val lastReducedBalanceBy = mutableStateOf(BigDecimal.ZERO)
private val swapRouter: SwapRouter = SwapRouter(router = router) private val swapRouter: SwapRouter = SwapRouter(router = router)
@ -427,20 +429,22 @@ internal class SwapModel @Inject constructor(
selectedAccountCurrency?.status to selectedAccountCurrency?.account selectedAccountCurrency?.status to selectedAccountCurrency?.account
} }
applyInitialTokenChoice( val isApplied = applyInitialTokenChoice(
state = state, state = state,
selectedCurrency = selectedCurrency, selectedCurrency = selectedCurrency,
selectedAccount = selectedAccount, selectedAccount = selectedAccount,
isReverseFromTo = isReverseFromTo, isReverseFromTo = isReverseFromTo,
) )
val fromCryptoCurrency = if (isOrderReversed) { // assume that fromCryptoCurrency selected according reverse flag,
dataState.toCryptoCurrency // so update fee paid currency according to it
} else { val fromCryptoCurrency = dataState.fromCryptoCurrency
dataState.fromCryptoCurrency
}
if (fromCryptoCurrency != null) { if (isApplied && fromCryptoCurrency != null) {
TangemLogger.i(
"updateFeePaidCryptoCurrencyFor: id = ${fromCryptoCurrency.currency.id}, " +
"isReverseFromTo: $isReverseFromTo",
)
updateFeePaidCryptoCurrencyFor(fromCryptoCurrency) updateFeePaidCryptoCurrencyFor(fromCryptoCurrency)
} else { } else {
TangemLogger.e("updateFeePaidCryptoCurrencyFor failed: fromCryptoCurrency is null") TangemLogger.e("updateFeePaidCryptoCurrencyFor failed: fromCryptoCurrency is null")
@ -498,7 +502,7 @@ internal class SwapModel @Inject constructor(
state = state, state = state,
selectedCurrency = selectedCurrency, selectedCurrency = selectedCurrency,
selectedAccount = selectedAccount, selectedAccount = selectedAccount,
isReverseFromTo = isOrderReversed, isReverseFromTo = isOrderReversed.value,
) )
subscribeToCoinBalanceUpdatesIfNeeded() 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( private fun applyInitialTokenChoice(
state: TokensDataStateExpress, state: TokensDataStateExpress,
selectedCurrency: CryptoCurrencyStatus?, selectedCurrency: CryptoCurrencyStatus?,
selectedAccount: Account.CryptoPortfolio?, selectedAccount: Account.CryptoPortfolio?,
isReverseFromTo: Boolean, isReverseFromTo: Boolean,
) { ): Boolean {
// exceptional case // exceptional case
if (selectedCurrency == null) { if (selectedCurrency == null) {
TangemLogger.e("No available tokens to swap for ${initialCurrencyFrom.symbol}")
analyticsEventHandler.send(SwapEvents.NoticeNoAvailableTokensToSwap()) analyticsEventHandler.send(SwapEvents.NoticeNoAvailableTokensToSwap())
uiState = stateBuilder.createNoAvailableTokensToSwapState( uiState = stateBuilder.createNoAvailableTokensToSwapState(
uiStateHolder = uiState, uiStateHolder = uiState,
fromToken = initialFromStatus, fromToken = initialFromStatus,
) )
return return false
} }
isOrderReversed = isReverseFromTo isOrderReversed.value = isReverseFromTo
val (fromCurrencyStatus, toCurrencyStatus) = if (isOrderReversed) { val (fromCurrencyStatus, toCurrencyStatus) = if (isOrderReversed.value) {
selectedCurrency to initialFromStatus selectedCurrency to initialFromStatus
} else { } else {
initialFromStatus to selectedCurrency initialFromStatus to selectedCurrency
} }
val (fromAccount, toAccount) = if (canUseFromAccountCurrencyStatus) { val (fromAccount, toAccount) = if (canUseFromAccountCurrencyStatus) {
if (isOrderReversed) { if (isOrderReversed.value) {
selectedAccount to requireNotNull(fromAccountCurrencyStatus).account selectedAccount to requireNotNull(fromAccountCurrencyStatus).account
} else { } else {
requireNotNull(fromAccountCurrencyStatus).account to selectedAccount requireNotNull(fromAccountCurrencyStatus).account to selectedAccount
@ -573,7 +582,7 @@ internal class SwapModel @Inject constructor(
toAccount = toAccount, toAccount = toAccount,
) )
) { ) {
return return true
} }
startLoadingQuotes( startLoadingQuotes(
@ -585,10 +594,11 @@ internal class SwapModel @Inject constructor(
reduceBalanceBy = lastReducedBalanceBy.value, reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(fromCurrencyStatus, toCurrencyStatus), toProvidersList = findSwapProviders(fromCurrencyStatus, toCurrencyStatus),
) )
return true
} }
private fun updateTokensState(tokenDataState: TokensDataStateExpress) { 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) chooseTokenBridge.updateCurrenciesGroup(tokensDataState)
} }
@ -1162,7 +1172,7 @@ internal class SwapModel @Inject constructor(
private fun onSearchEntered(searchQuery: String) { private fun onSearchEntered(searchQuery: String) {
val tokenDataState = dataState.tokensDataState ?: return val tokenDataState = dataState.tokensDataState ?: return
val group = if (isOrderReversed) { val group = if (isOrderReversed.value) {
tokenDataState.fromGroup tokenDataState.fromGroup
} else { } else {
tokenDataState.toGroup tokenDataState.toGroup
@ -1192,7 +1202,7 @@ internal class SwapModel @Inject constructor(
) )
} }
val filteredTokenDataState = if (isOrderReversed) { val filteredTokenDataState = if (isOrderReversed.value) {
tokenDataState.copy( tokenDataState.copy(
fromGroup = tokenDataState.fromGroup.copy( fromGroup = tokenDataState.fromGroup.copy(
available = available, available = available,
@ -1238,7 +1248,7 @@ internal class SwapModel @Inject constructor(
val fromAccount: Account.CryptoPortfolio? val fromAccount: Account.CryptoPortfolio?
val toToken: CryptoCurrencyStatus val toToken: CryptoCurrencyStatus
val toAccount: Account.CryptoPortfolio? val toAccount: Account.CryptoPortfolio?
if (isOrderReversed) { if (isOrderReversed.value) {
fromToken = foundToken fromToken = foundToken
fromAccount = foundAccount fromAccount = foundAccount
toToken = initialFromStatus toToken = initialFromStatus
@ -1295,6 +1305,17 @@ internal class SwapModel @Inject constructor(
return return
} }
modelScope.launch { 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) updateFeePaidCryptoCurrencyFor(fromToken)
startLoadingQuotes( startLoadingQuotes(
fromToken = fromToken, fromToken = fromToken,
@ -1314,7 +1335,7 @@ internal class SwapModel @Inject constructor(
tokens: TokensDataStateExpress, tokens: TokensDataStateExpress,
id: String, id: String,
): Pair<CryptoCurrencyStatus?, Account.CryptoPortfolio?> { ): Pair<CryptoCurrencyStatus?, Account.CryptoPortfolio?> {
val accountCryptoCurrencyStatus = if (isOrderReversed) { val accountCryptoCurrencyStatus = if (isOrderReversed.value) {
tokens.fromGroup tokens.fromGroup
} else { } else {
tokens.toGroup tokens.toGroup
@ -1346,7 +1367,23 @@ internal class SwapModel @Inject constructor(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId, userWalletId = userWalletId,
cryptoCurrencyStatus = currencyStatus, 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, toCryptoCurrency = newToToken,
toAccount = newToAccount, toAccount = newToAccount,
) )
isOrderReversed = !isOrderReversed isOrderReversed.value = !isOrderReversed.value
TangemLogger.i(
"updateFeePaidCryptoCurrencyFor: id = ${newFromToken.currency.id}, " +
"isOrderReversed: ${isOrderReversed.value}",
)
updateFeePaidCryptoCurrencyFor(newFromToken) updateFeePaidCryptoCurrencyFor(newFromToken)
dataState.tokensDataState?.let { tokensDataState -> dataState.tokensDataState?.let { tokensDataState ->
updateTokensState(tokensDataState) updateTokensState(tokensDataState)
@ -1824,13 +1865,13 @@ internal class SwapModel @Inject constructor(
} }
private fun findSwapProviders(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): List<SwapProvider> { private fun findSwapProviders(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): List<SwapProvider> {
val groupToFind = if (isOrderReversed) { val groupToFind = if (isOrderReversed.value) {
dataState.tokensDataState?.fromGroup dataState.tokensDataState?.fromGroup
} else { } else {
dataState.tokensDataState?.toGroup dataState.tokensDataState?.toGroup
} ?: return emptyList() } ?: return emptyList()
val idToFind = if (isOrderReversed) { val idToFind = if (isOrderReversed.value) {
fromToken.currency.id.value fromToken.currency.id.value
} else { } else {
toToken.currency.id.value toToken.currency.id.value
@ -1856,8 +1897,8 @@ internal class SwapModel @Inject constructor(
fromAccount: Account.CryptoPortfolio?, fromAccount: Account.CryptoPortfolio?,
toAccount: Account.CryptoPortfolio?, toAccount: Account.CryptoPortfolio?,
): Boolean { ): Boolean {
val selectedCurrency = if (isOrderReversed) fromToken else toToken val selectedCurrency = if (isOrderReversed.value) fromToken else toToken
if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed)) return false if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed.value)) return false
analyticsEventHandler.send( analyticsEventHandler.send(
SwapEvents.NoticeUnavailableToSwapPair( SwapEvents.NoticeUnavailableToSwapPair(
@ -1867,6 +1908,14 @@ internal class SwapModel @Inject constructor(
receiveBlockchain = toToken.currency.network.name, 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( uiState = stateBuilder.createSwapNotSupportedState(
uiStateHolder = uiState, uiStateHolder = uiState,
fromToken = fromToken, fromToken = fromToken,
@ -1917,13 +1966,13 @@ internal class SwapModel @Inject constructor(
val from = dataState.fromCryptoCurrency ?: return false val from = dataState.fromCryptoCurrency ?: return false
val to = dataState.toCryptoCurrency ?: return false val to = dataState.toCryptoCurrency ?: return false
val currenciesGroup = if (isOrderReversed) { val currenciesGroup = if (isOrderReversed.value) {
dataState.tokensDataState?.toGroup dataState.tokensDataState?.toGroup
} else { } else {
dataState.tokensDataState?.fromGroup dataState.tokensDataState?.fromGroup
} ?: return false } ?: return false
val chosen = if (isOrderReversed) from else to val chosen = if (isOrderReversed.value) from else to
return currenciesGroup.accountCurrencyList.flatMap { accountSwapAvailability -> return currenciesGroup.accountCurrencyList.flatMap { accountSwapAvailability ->
accountSwapAvailability.currencyList.map { accountSwapCurrency -> accountSwapAvailability.currencyList.map { accountSwapCurrency ->

View file

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

View file

@ -771,7 +771,12 @@ internal class WalletModel @Inject constructor(
} }
private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) { 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 -> { is QrSendTarget.WalletConnect -> {
val source = when (resultSource) { val source = when (resultSource) {
QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD
@ -801,6 +806,17 @@ internal class WalletModel @Inject constructor(
is QrSendTarget.Multiple -> { is QrSendTarget.Multiple -> {
innerWalletRouter.openNetworkSelectionBottomSheet(target) 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) is QrSendTarget.Error -> handleQrError(target.error)
} }
} }

View file

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

View file

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

View file

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

View file

@ -139,6 +139,7 @@ internal class WalletStateController @Inject constructor(
isHidingMode = false, isHidingMode = false,
showMarketsOnboarding = false, showMarketsOnboarding = false,
onDismissMarketsTooltip = {}, 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 { fun confirmExpressStatusHide(onConfirmClick: () -> Unit): DialogMessage {
return DialogMessage( return DialogMessage(
title = resourceReference(R.string.express_status_hide_dialog_title), title = resourceReference(R.string.express_status_hide_dialog_title),

View file

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

View file

@ -15,17 +15,22 @@ internal abstract class WalletStateTransformer(
abstract fun transform(walletUM: WalletUM): WalletUM abstract fun transform(walletUM: WalletUM): WalletUM
final override fun transform(prevState: WalletScreenState): WalletScreenState { final override fun transform(prevState: WalletScreenState): WalletScreenState {
return prevState.copy( return if (prevState.isRedesignEnabled) {
wallets = prevState.wallets prevState.copy(
.map { state -> wallets2 = prevState.wallets2
if (state.walletCardState.id == userWalletId) transform(state) else state .map { walletUM ->
} if (walletUM.walletsBalanceUM.id == userWalletId) transform(walletUM) else walletUM
.toImmutableList(), }
wallets2 = prevState.wallets2 .toImmutableList(),
.map { walletUM -> )
if (walletUM.walletsBalanceUM.id == userWalletId) transform(walletUM) else walletUM } else {
} prevState.copy(
.toImmutableList(), 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.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@ -107,6 +108,7 @@ internal fun WalletTopBar(
* *
* @param config component config * @param config component config
*/ */
@Suppress("MagicNumber")
@Deprecated("Remove with main toggle [DesignFeatureToggles.isRedesignEnabled]") @Deprecated("Remove with main toggle [DesignFeatureToggles.isRedesignEnabled]")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -122,7 +124,9 @@ internal fun WalletTopBar(config: WalletTopBarConfig) {
Icon( Icon(
painter = painterResource(id = action.iconRes), painter = painterResource(id = action.iconRes),
contentDescription = null, 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/tangem-sdk-android/
# https://github.com/tangem/vico # https://github.com/tangem/vico
tangemBlockchainSdk = "develop-1461" tangemBlockchainSdk = "develop-1473"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds #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 ^ #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
tangemVico = "tangem-master-21" tangemVico = "tangem-master-21"
#tangemVico = "0.0.1" # Keep it! - used for local builds ^ #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 ^ #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^