Updated on 2026-08-14
This commit is contained in:
commit
dcda504df6
40 changed files with 1442 additions and 182 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit 636d7a8aa0e330e9b95e91d85f23ad15ac6d913f
|
||||
Subproject commit 009cf6332a72cf0893167221abf7010d033906c2
|
||||
|
|
@ -2,6 +2,7 @@ package com.tangem.tap.di.domain
|
|||
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.networks.repository.NetworksRepository
|
||||
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
||||
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
|
||||
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||
|
|
@ -41,11 +42,13 @@ internal object QrScanningDomainModule {
|
|||
multiAccountListSupplier: MultiAccountListSupplier,
|
||||
qrScanningEventsRepository: QrScanningEventsRepository,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
networksRepository: NetworksRepository,
|
||||
): ResolveQrSendTargetsUseCase {
|
||||
return ResolveQrSendTargetsUseCase(
|
||||
multiAccountListSupplier = multiAccountListSupplier,
|
||||
qrScanningEventsRepository = qrScanningEventsRepository,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
networksRepository = networksRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -51,9 +51,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Suppress("LongParameterList", "LargeClass")
|
||||
@HiltViewModel
|
||||
|
|
@ -142,10 +140,7 @@ internal class MainViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
launchAPIRequests {
|
||||
launch { blockchainSDKFactory.init() }
|
||||
|
||||
launch {
|
||||
withTimeout(timeMillis = 1.seconds.inWholeMilliseconds) { fetchUserCountry() }
|
||||
}
|
||||
launch { fetchUserCountry() }
|
||||
}
|
||||
|
||||
subscribeToSelectedWallet()
|
||||
|
|
|
|||
|
|
@ -72,7 +72,12 @@ object NotificationsFactory {
|
|||
)
|
||||
is GetFeeError.BlockchainErrors.SuiOneCoinRequired ->
|
||||
add(NotificationUM.Sui.NotEnoughCoinForTokenTransaction)
|
||||
is GetFeeError.DataError -> when (feeError.cause) {
|
||||
is GetFeeError.DataError -> when (val cause = feeError.cause) {
|
||||
is BlockchainSdkError.Kaspa.DustChangeError -> add(
|
||||
NotificationUM.Error.MinimumAmountError(
|
||||
amount = cause.minimumAmount.format { crypto(tokenStatus.currency) },
|
||||
),
|
||||
)
|
||||
BlockchainSdkError.TransactionDustChangeError -> add(
|
||||
NotificationUM.Error.MinimumAmountError(
|
||||
amount = dustValue.format { crypto(tokenStatus.currency) },
|
||||
|
|
@ -388,6 +393,11 @@ object NotificationsFactory {
|
|||
rentExemptionAmount = validationError.rentAmount,
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
)
|
||||
is BlockchainSdkError.Kaspa.DustChangeError -> add(
|
||||
NotificationUM.Error.MinimumAmountError(
|
||||
amount = validationError.minimumAmount.format { crypto(cryptoCurrency) },
|
||||
),
|
||||
)
|
||||
is BlockchainSdkError.TransactionDustChangeError -> add(
|
||||
NotificationUM.Error.MinimumAmountError(
|
||||
amount = dustValue.format { crypto(cryptoCurrency) },
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ fun Transaction(state: TransactionState, isBalanceHidden: Boolean, modifier: Mod
|
|||
top.linkTo(parent.top)
|
||||
bottom.linkTo(timestampItem.top)
|
||||
end.linkTo(parent.end)
|
||||
width = Dimension.fillToConstraints
|
||||
width = Dimension.wrapContent
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -274,6 +274,8 @@ private fun Amount(state: TransactionState, isBalanceHidden: Boolean, modifier:
|
|||
}
|
||||
},
|
||||
style = TangemTheme.typography.body2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
is TransactionState.Loading -> {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ internal class DefaultNetworksRepository(
|
|||
.map { currency ->
|
||||
CryptoCurrencyAddress(
|
||||
cryptoCurrency = currency,
|
||||
address = getDefaultAddress(userWalletId, network),
|
||||
address = getDefaultAddress(userWalletId, network).orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -74,11 +74,17 @@ internal class DefaultNetworksRepository(
|
|||
.map { currency ->
|
||||
CryptoCurrencyAddress(
|
||||
cryptoCurrency = currency,
|
||||
address = getDefaultAddress(userWalletId, currency.network),
|
||||
address = getDefaultAddress(userWalletId, currency.network).orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDefaultAddress(userWalletId: UserWalletId, network: Network): String? {
|
||||
return withContext(dispatchers.io) {
|
||||
walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun hasCachedStatuses(userWalletId: UserWalletId): Boolean {
|
||||
return networksStatusesStore.contains(userWalletId)
|
||||
}
|
||||
|
|
@ -100,10 +106,4 @@ internal class DefaultNetworksRepository(
|
|||
|
||||
networksStatusesStore.storeStatus(userWalletId = userWalletId, status = networkStatus)
|
||||
}
|
||||
|
||||
private suspend fun getDefaultAddress(userWalletId: UserWalletId, network: Network): String {
|
||||
return withContext(dispatchers.io) {
|
||||
walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network).orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ package com.tangem.data.qrscanning.di
|
|||
import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser
|
||||
import com.tangem.data.qrscanning.parser.Eip681PaymentUriParser
|
||||
import com.tangem.data.qrscanning.parser.QrContentClassifierParser
|
||||
import com.tangem.data.qrscanning.parser.SolanaPaymentUriParser
|
||||
import com.tangem.data.qrscanning.parser.TronPaymentUriParser
|
||||
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
|
||||
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
||||
import dagger.Module
|
||||
|
|
@ -24,6 +26,8 @@ internal object QrScanningDataModule {
|
|||
blockchainDataProvider = blockchainDataProvider,
|
||||
paymentUriParsers = setOf(
|
||||
Eip681PaymentUriParser(blockchainDataProvider),
|
||||
TronPaymentUriParser(blockchainDataProvider),
|
||||
SolanaPaymentUriParser(blockchainDataProvider),
|
||||
Bip321PaymentUriParser(blockchainDataProvider),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
package com.tangem.data.qrscanning.parser
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
|
||||
internal class Bip321PaymentUriParser(
|
||||
private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider,
|
||||
private val helper: PaymentUriResolveHelper = PaymentUriResolveHelper(),
|
||||
) : PaymentUriParser {
|
||||
|
||||
override fun parse(
|
||||
|
|
@ -12,52 +15,69 @@ internal class Bip321PaymentUriParser(
|
|||
coins: List<CryptoCurrency.Coin>,
|
||||
allCurrencies: List<CryptoCurrency>,
|
||||
): PaymentUriParser.ParseResult {
|
||||
val schemeAndRest = extractSchemeAndRest(qrCode, coins)
|
||||
val (scheme, blockchains) = SCHEME_TO_BLOCKCHAINS.entries
|
||||
.firstOrNull { (scheme, _) -> qrCode.startsWith(scheme, ignoreCase = true) }
|
||||
?: return PaymentUriParser.ParseResult.NotRecognized
|
||||
val (matchingCoins, withoutScheme) = schemeAndRest
|
||||
|
||||
val parsed = QrSentUriParser().parse(withoutScheme)
|
||||
val parsed = helper.parseUri(qrCode, scheme)
|
||||
?: return PaymentUriParser.ParseResult.RecognizedError(
|
||||
ClassifiedQrContent.Error.Unrecognized(qrCode),
|
||||
)
|
||||
|
||||
val matchingCoins = coins.filter { it.network.toBlockchain() in blockchains }
|
||||
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
|
||||
val matchingCurrencies = allCurrencies.filter { it.network.id in matchingNetworkIds }
|
||||
|
||||
if (matchingCurrencies.isEmpty()) {
|
||||
return PaymentUriParser.ParseResult.RecognizedError(
|
||||
ClassifiedQrContent.Error.UnsupportedNetwork(
|
||||
raw = qrCode,
|
||||
blockchain = matchingCoins.firstOrNull()?.network?.name,
|
||||
blockchain = blockchains.first().fullName,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return PaymentUriParser.ParseResult.Success(
|
||||
val isAddressValid = matchingCoins.any {
|
||||
blockchainDataProvider.validateAddress(it.network, parsed.address)
|
||||
}
|
||||
if (!isAddressValid) {
|
||||
return PaymentUriParser.ParseResult.RecognizedError(
|
||||
ClassifiedQrContent.Error.Unrecognized(qrCode),
|
||||
)
|
||||
}
|
||||
|
||||
val result = PaymentUriParser.ParseResult.Success(
|
||||
ClassifiedQrContent.PaymentUri(
|
||||
address = parsed.address,
|
||||
amount = parsed.amount,
|
||||
memo = parsed.memo,
|
||||
memo = parsed.memo?.second,
|
||||
matchingCurrencies = matchingCurrencies,
|
||||
),
|
||||
)
|
||||
|
||||
val unconsumed = parsed.remainingParams - PARAM_LABEL
|
||||
return helper.validateParams(
|
||||
result = result,
|
||||
unconsumedParams = unconsumed,
|
||||
memo = parsed.memo,
|
||||
matchingCoins = matchingCoins,
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractSchemeAndRest(
|
||||
qrCode: String,
|
||||
coins: List<CryptoCurrency.Coin>,
|
||||
): Pair<List<CryptoCurrency.Coin>, String>? {
|
||||
for (coin in coins) {
|
||||
val schemes = blockchainDataProvider.getShareSchemes(coin.network)
|
||||
for (scheme in schemes) {
|
||||
if (qrCode.startsWith(scheme, ignoreCase = true)) {
|
||||
val withoutScheme = qrCode.removeRange(0, scheme.length)
|
||||
val allMatchingCoins = coins.filter { c ->
|
||||
blockchainDataProvider.getShareSchemes(c.network).any { it.equals(scheme, ignoreCase = true) }
|
||||
}
|
||||
return allMatchingCoins to withoutScheme
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
private companion object {
|
||||
const val PARAM_LABEL = "label"
|
||||
val BLOCKCHAINS = listOf(
|
||||
Blockchain.Bitcoin,
|
||||
Blockchain.BitcoinTestnet,
|
||||
Blockchain.Litecoin,
|
||||
Blockchain.Binance,
|
||||
Blockchain.BinanceTestnet,
|
||||
Blockchain.Dogecoin,
|
||||
Blockchain.XRP,
|
||||
)
|
||||
val SCHEME_TO_BLOCKCHAINS: Map<String, Set<Blockchain>> = BLOCKCHAINS
|
||||
.flatMap { blockchain -> blockchain.getShareScheme().map { scheme -> scheme to blockchain } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.mapValues { it.value.toSet() }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.data.qrscanning.parser
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import java.math.BigDecimal
|
||||
|
|
@ -14,11 +15,10 @@ internal class Eip681PaymentUriParser(
|
|||
coins: List<CryptoCurrency.Coin>,
|
||||
allCurrencies: List<CryptoCurrency>,
|
||||
): PaymentUriParser.ParseResult {
|
||||
if (!qrCode.startsWith(SCHEME)) {
|
||||
return PaymentUriParser.ParseResult.NotRecognized
|
||||
}
|
||||
val scheme = SCHEMES.find { qrCode.startsWith(it, ignoreCase = true) }
|
||||
?: return PaymentUriParser.ParseResult.NotRecognized
|
||||
|
||||
val withoutScheme = qrCode.removePrefix(SCHEME)
|
||||
val withoutScheme = qrCode.removePrefix(scheme)
|
||||
val parsed = parseEip681(withoutScheme) ?: return PaymentUriParser.ParseResult.NotRecognized
|
||||
|
||||
val matchingCoins = findMatchingCoins(parsed.chainId, coins)
|
||||
|
|
@ -31,8 +31,26 @@ internal class Eip681PaymentUriParser(
|
|||
)
|
||||
}
|
||||
|
||||
val isAddressValid = matchingCoins.any { coin ->
|
||||
blockchainDataProvider.validateAddress(coin.network, parsed.targetAddress)
|
||||
}
|
||||
if (!isAddressValid) {
|
||||
return PaymentUriParser.ParseResult.RecognizedError(
|
||||
ClassifiedQrContent.Error.Unrecognized(qrCode),
|
||||
)
|
||||
}
|
||||
|
||||
val result = if (parsed.functionName == FUNCTION_TRANSFER) {
|
||||
if (PARAM_ADDRESS !in parsed.params) {
|
||||
val recipient = parsed.params[Param.ADDRESS.key]
|
||||
if (recipient == null) {
|
||||
return PaymentUriParser.ParseResult.RecognizedError(
|
||||
ClassifiedQrContent.Error.Unrecognized(qrCode),
|
||||
)
|
||||
}
|
||||
val isRecipientValid = matchingCoins.any { coin ->
|
||||
blockchainDataProvider.validateAddress(coin.network, recipient)
|
||||
}
|
||||
if (!isRecipientValid) {
|
||||
return PaymentUriParser.ParseResult.RecognizedError(
|
||||
ClassifiedQrContent.Error.Unrecognized(qrCode),
|
||||
)
|
||||
|
|
@ -41,16 +59,30 @@ internal class Eip681PaymentUriParser(
|
|||
} else {
|
||||
resolveNativeTransfer(parsed, matchingCoins, allCurrencies)
|
||||
}
|
||||
return if (result != null) {
|
||||
PaymentUriParser.ParseResult.Success(result)
|
||||
} else {
|
||||
PaymentUriParser.ParseResult.RecognizedError(
|
||||
if (result == null) {
|
||||
return PaymentUriParser.ParseResult.RecognizedError(
|
||||
ClassifiedQrContent.Error.UnsupportedNetwork(
|
||||
raw = qrCode,
|
||||
blockchain = matchingCoins.firstOrNull()?.network?.name,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val unsupportedParams = findUnsupportedParams(parsed.params, parsed.functionName)
|
||||
return if (unsupportedParams.isEmpty()) {
|
||||
PaymentUriParser.ParseResult.Success(result)
|
||||
} else {
|
||||
PaymentUriParser.ParseResult.SuccessWithWarning(result, unsupportedParams)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findUnsupportedParams(params: Map<String, String>, functionName: String?): Map<String, String> {
|
||||
val supportedKeys = if (functionName == FUNCTION_TRANSFER) {
|
||||
Param.transferParams()
|
||||
} else {
|
||||
Param.nativeParams()
|
||||
}
|
||||
return params.filterKeys { key -> key !in supportedKeys }
|
||||
}
|
||||
|
||||
private fun resolveNativeTransfer(
|
||||
|
|
@ -58,7 +90,7 @@ internal class Eip681PaymentUriParser(
|
|||
matchingCoins: List<CryptoCurrency.Coin>,
|
||||
allCurrencies: List<CryptoCurrency>,
|
||||
): ClassifiedQrContent.PaymentUri? {
|
||||
val valueWei = parsed.params[PARAM_VALUE]?.toBigDecimalOrNull()
|
||||
val valueWei = parsed.params[Param.VALUE.key]?.toBigDecimalOrNull()
|
||||
|
||||
if (matchingCoins.isEmpty()) return null
|
||||
|
||||
|
|
@ -87,7 +119,7 @@ internal class Eip681PaymentUriParser(
|
|||
matchingCoins: List<CryptoCurrency.Coin>,
|
||||
allCurrencies: List<CryptoCurrency>,
|
||||
): ClassifiedQrContent.PaymentUri? {
|
||||
val recipient = parsed.params[PARAM_ADDRESS] ?: return null
|
||||
val recipient = parsed.params[Param.ADDRESS.key] ?: return null
|
||||
val contractAddress = parsed.targetAddress
|
||||
|
||||
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
|
||||
|
|
@ -100,7 +132,7 @@ internal class Eip681PaymentUriParser(
|
|||
|
||||
if (matchingTokens.isEmpty()) return null
|
||||
|
||||
val rawAmount = parsed.params[PARAM_UINT256]?.toBigDecimalOrNull()
|
||||
val rawAmount = parsed.params[Param.UINT256.key]?.toBigDecimalOrNull()
|
||||
val amount = rawAmount?.fromSmallestUnit(matchingTokens.first().decimals)
|
||||
|
||||
return ClassifiedQrContent.PaymentUri(
|
||||
|
|
@ -114,7 +146,7 @@ internal class Eip681PaymentUriParser(
|
|||
private fun findMatchingCoins(chainId: Long?, coins: List<CryptoCurrency.Coin>): List<CryptoCurrency.Coin> {
|
||||
if (chainId == null) {
|
||||
return coins.filter { coin ->
|
||||
blockchainDataProvider.getShareSchemes(coin.network).any { it.startsWith(SCHEME) }
|
||||
blockchainDataProvider.getChainId(coin.network) != null
|
||||
}
|
||||
}
|
||||
return coins.filter { coin ->
|
||||
|
|
@ -131,7 +163,7 @@ internal class Eip681PaymentUriParser(
|
|||
val queryString = match.groupValues[GROUP_QUERY]
|
||||
|
||||
val params = parseQueryParams(queryString)
|
||||
val chainId = pathChainId ?: params[PARAM_CHAIN_ID]?.toLongOrNull()
|
||||
val chainId = pathChainId ?: params[Param.CHAIN_ID.key]?.toLongOrNull()
|
||||
|
||||
return Eip681Result(
|
||||
targetAddress = targetAddress,
|
||||
|
|
@ -161,15 +193,27 @@ internal class Eip681PaymentUriParser(
|
|||
val params: Map<String, String>,
|
||||
)
|
||||
|
||||
private enum class Param(val key: String) {
|
||||
VALUE("value"),
|
||||
ADDRESS("address"),
|
||||
UINT256("uint256"),
|
||||
CHAIN_ID("chainId"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** Params supported for native transfers (no function or unknown function). */
|
||||
fun nativeParams(): Set<String> = setOf(VALUE.key, CHAIN_ID.key)
|
||||
|
||||
/** Params supported for ERC-20 transfer() calls. */
|
||||
fun transferParams(): Set<String> = setOf(ADDRESS.key, UINT256.key, CHAIN_ID.key)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// ethereum:<address>[@<chainId>][/<function>][?<params>]
|
||||
val URI_REGEX = Regex("""^([^@/?]+)(?:@(\d+))?(?:/([^?]+))?(?:\?(.+))?$""")
|
||||
const val SCHEME = "ethereum:"
|
||||
val SCHEMES = Blockchain.Ethereum.getShareScheme()
|
||||
const val FUNCTION_TRANSFER = "transfer"
|
||||
const val PARAM_VALUE = "value"
|
||||
const val PARAM_ADDRESS = "address"
|
||||
const val PARAM_UINT256 = "uint256"
|
||||
const val PARAM_CHAIN_ID = "chainId"
|
||||
const val GROUP_ADDRESS = 1
|
||||
const val GROUP_CHAIN_ID = 2
|
||||
const val GROUP_FUNCTION = 3
|
||||
|
|
|
|||
|
|
@ -16,5 +16,11 @@ internal interface PaymentUriParser {
|
|||
|
||||
/** Successfully parsed with matching currencies. */
|
||||
data class Success(val content: ClassifiedQrContent.PaymentUri) : ParseResult()
|
||||
|
||||
/** Successfully parsed but QR contains unsupported parameters. */
|
||||
data class SuccessWithWarning(
|
||||
val content: ClassifiedQrContent.PaymentUri,
|
||||
val unsupportedParams: Map<String, String>,
|
||||
) : ParseResult()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -26,6 +26,12 @@ internal class QrContentClassifierParser(
|
|||
|
||||
when (val paymentUriResult = tryParsePaymentUri(qrCode, uniqueCoins, userCurrencies)) {
|
||||
is PaymentUriParser.ParseResult.Success -> return paymentUriResult.content
|
||||
is PaymentUriParser.ParseResult.SuccessWithWarning -> {
|
||||
return ClassifiedQrContent.PaymentUriWarning(
|
||||
paymentUri = paymentUriResult.content,
|
||||
unsupportedParams = paymentUriResult.unsupportedParams,
|
||||
)
|
||||
}
|
||||
is PaymentUriParser.ParseResult.RecognizedError -> return paymentUriResult.error
|
||||
is PaymentUriParser.ParseResult.NotRecognized -> Unit
|
||||
}
|
||||
|
|
@ -77,7 +83,6 @@ internal class QrContentClassifierParser(
|
|||
}
|
||||
|
||||
internal interface BlockchainDataProvider {
|
||||
fun getShareSchemes(network: Network): List<String>
|
||||
fun validateAddress(network: Network, address: String): Boolean
|
||||
fun getChainId(network: Network): Long?
|
||||
fun findSupportedBlockchainName(address: String): String?
|
||||
|
|
@ -85,10 +90,6 @@ internal class QrContentClassifierParser(
|
|||
}
|
||||
|
||||
internal class DefaultBlockchainDataProvider : BlockchainDataProvider {
|
||||
override fun getShareSchemes(network: Network): List<String> {
|
||||
return runCatching { network.toBlockchain().getShareScheme() }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
override fun validateAddress(network: Network, address: String): Boolean {
|
||||
return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ internal class QrSentUriParser {
|
|||
data class Result(
|
||||
val address: String,
|
||||
val amount: BigDecimal?,
|
||||
val memo: String?,
|
||||
val params: Map<String, String>,
|
||||
val memo: Pair<String, String>?,
|
||||
val remainingParams: Map<String, String>,
|
||||
)
|
||||
|
||||
fun parse(withoutScheme: String): Result? {
|
||||
|
|
@ -20,15 +20,24 @@ internal class QrSentUriParser {
|
|||
|
||||
val params = extractParameters(withoutScheme)
|
||||
val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull()
|
||||
val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let {
|
||||
runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it)
|
||||
|
||||
val memoKey = MemoParam.keys.firstOrNull { it in params }
|
||||
val memo = memoKey?.let { key ->
|
||||
val raw = params[key] ?: return@let null
|
||||
val decoded = runCatching { URLDecoder.decode(raw, CHARSET_UTF8) }.getOrDefault(raw)
|
||||
key to decoded
|
||||
}
|
||||
|
||||
val consumedKeys = buildSet {
|
||||
add(PARAM_AMOUNT)
|
||||
addAll(MemoParam.keys)
|
||||
}
|
||||
|
||||
return Result(
|
||||
address = address,
|
||||
amount = amount,
|
||||
memo = memo,
|
||||
params = params,
|
||||
remainingParams = params - consumedKeys,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +53,17 @@ internal class QrSentUriParser {
|
|||
.toMap()
|
||||
}
|
||||
|
||||
enum class MemoParam(val key: String) {
|
||||
MEMO("memo"),
|
||||
MESSAGE("message"),
|
||||
DESTINATION_TAG("dt"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
val keys = entries.map { it.key }.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHAIN_DELIMITER = '@'
|
||||
const val FUNCTION_DELIMITER = '/'
|
||||
|
|
@ -51,8 +71,6 @@ internal class QrSentUriParser {
|
|||
const val PARAMS_DELIMITER = '&'
|
||||
const val PARAM_VALUE_DELIMITER = '='
|
||||
const val PARAM_AMOUNT = "amount"
|
||||
const val PARAM_MEMO = "memo"
|
||||
const val PARAM_MESSAGE = "message"
|
||||
const val PARAM_ADDRESS = "address"
|
||||
const val PARAM_VALUE = "value"
|
||||
const val PARAM_UINT256 = "uint256"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -47,11 +47,11 @@ internal class DefaultQrScanningEventsRepository(
|
|||
|
||||
val result = QrResult(address = parsed.address)
|
||||
result.amount = parsed.amount
|
||||
result.memo = parsed.memo
|
||||
result.memo = parsed.memo?.second
|
||||
|
||||
// ERC-681: if 'address' parameter exists, currency must be a token,
|
||||
// and the URI address must match the token's contract address.
|
||||
parsed.params[QrSentUriParser.PARAM_ADDRESS]?.let { addressValue ->
|
||||
parsed.remainingParams[QrSentUriParser.PARAM_ADDRESS]?.let { addressValue ->
|
||||
val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult()
|
||||
if (tokenCurrency.contractAddress.equals(parsed.address, ignoreCase = true)) {
|
||||
result.address = addressValue
|
||||
|
|
@ -61,8 +61,8 @@ internal class DefaultQrScanningEventsRepository(
|
|||
}
|
||||
|
||||
// ERC-681: value/uint256 is in the smallest unit, needs conversion
|
||||
val valueStr = parsed.params[QrSentUriParser.PARAM_VALUE]
|
||||
?: parsed.params[QrSentUriParser.PARAM_UINT256]
|
||||
val valueStr = parsed.remainingParams[QrSentUriParser.PARAM_VALUE]
|
||||
?: parsed.remainingParams[QrSentUriParser.PARAM_UINT256]
|
||||
if (valueStr != null) {
|
||||
result.amount = valueStr.parseBigDecimalOrNull()
|
||||
?.toPlainString()?.toBigDecimalOrNull()
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import java.math.BigDecimal
|
|||
internal class Bip321PaymentUriParserTest {
|
||||
|
||||
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
|
||||
every { getShareSchemes(any()) } returns emptyList()
|
||||
every { validateAddress(any(), any()) } returns true
|
||||
}
|
||||
private val parser = Bip321PaymentUriParser(blockchainDataProvider)
|
||||
|
||||
|
|
@ -23,8 +23,6 @@ internal class Bip321PaymentUriParserTest {
|
|||
|
||||
@Test
|
||||
fun `bitcoin URI with address and amount`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5",
|
||||
coins = listOf(bitcoinCoin),
|
||||
|
|
@ -40,8 +38,6 @@ internal class Bip321PaymentUriParserTest {
|
|||
|
||||
@Test
|
||||
fun `bitcoin URI with address only`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
|
||||
coins = listOf(bitcoinCoin),
|
||||
|
|
@ -56,8 +52,6 @@ internal class Bip321PaymentUriParserTest {
|
|||
|
||||
@Test
|
||||
fun `bitcoin URI with amount and message`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.23&message=Donation",
|
||||
coins = listOf(bitcoinCoin),
|
||||
|
|
@ -72,8 +66,6 @@ internal class Bip321PaymentUriParserTest {
|
|||
|
||||
@Test
|
||||
fun `bitcoin URI with label and message`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?label=Satoshi&message=Payment",
|
||||
coins = listOf(bitcoinCoin),
|
||||
|
|
@ -90,8 +82,6 @@ internal class Bip321PaymentUriParserTest {
|
|||
|
||||
@Test
|
||||
fun `litecoin URI matches litecoin coin`() {
|
||||
every { blockchainDataProvider.getShareSchemes(litecoinCoin.network) } returns listOf("litecoin:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "litecoin:LcHKx4Tt97hnGgR3CRUiB1gSQ3F8wMozLj?amount=10",
|
||||
coins = listOf(litecoinCoin),
|
||||
|
|
@ -105,11 +95,21 @@ internal class Bip321PaymentUriParserTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `no matching scheme returns NotRecognized`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
fun `dogecoin URI matches dogecoin coin`() {
|
||||
val result = parser.parse(
|
||||
qrCode = "dogecoin:DAddress?amount=100",
|
||||
qrCode = "doge:DAddress?amount=100",
|
||||
coins = listOf(dogecoinCoin),
|
||||
allCurrencies = listOf(dogecoinCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.address).isEqualTo("DAddress")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no matching scheme returns NotRecognized`() {
|
||||
val result = parser.parse(
|
||||
qrCode = "solana:SomeAddress?amount=100",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
)
|
||||
|
|
@ -118,22 +118,18 @@ internal class Bip321PaymentUriParserTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `ethereum scheme matches as Success`() {
|
||||
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:")
|
||||
|
||||
fun `ethereum scheme returns NotRecognized`() {
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xRecipient?value=1000",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin),
|
||||
).asSuccess()
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.NotRecognized::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case insensitive scheme matching`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "Bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1",
|
||||
coins = listOf(bitcoinCoin),
|
||||
|
|
@ -146,13 +142,41 @@ internal class Bip321PaymentUriParserTest {
|
|||
|
||||
// endregion
|
||||
|
||||
// region Unsupported network
|
||||
|
||||
@Test
|
||||
fun `invalid address returns Unrecognized error`() {
|
||||
every { blockchainDataProvider.validateAddress(any(), eq("InvalidBtcAddress")) } returns false
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:InvalidBtcAddress?amount=0.5",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
|
||||
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
|
||||
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bitcoin URI with no matching coin returns UnsupportedNetwork`() {
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5",
|
||||
coins = listOf(litecoinCoin),
|
||||
allCurrencies = listOf(litecoinCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Includes tokens on matching network
|
||||
|
||||
@Test
|
||||
fun `includes tokens on matching network`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
val btcToken = buildToken("bitcoin", "RUNE", "contractAddr")
|
||||
val btcToken = buildToken("BTC", "RUNE", "contractAddr")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.01",
|
||||
|
|
@ -191,17 +215,76 @@ internal class Bip321PaymentUriParserTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `bitcoin URI with memo param`() {
|
||||
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
|
||||
|
||||
fun `bitcoin URI with memo param returns warning for unsupported memo`() {
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&memo=TestMemo",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java)
|
||||
val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning
|
||||
assertThat(warning.content.memo).isEqualTo("TestMemo")
|
||||
assertThat(warning.unsupportedParams).containsEntry("memo", "TestMemo")
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Unsupported params
|
||||
|
||||
@Test
|
||||
fun `unknown parameter returns SuccessWithWarning`() {
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5&req-unknownparam=bar",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java)
|
||||
val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning
|
||||
assertThat(warning.content.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
|
||||
assertThat(warning.unsupportedParams).containsEntry("req-unknownparam", "bar")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple unknown parameters all reported`() {
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&foo=1&bar=2",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java)
|
||||
val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning
|
||||
assertThat(warning.unsupportedParams).hasSize(2)
|
||||
assertThat(warning.unsupportedParams).containsEntry("foo", "1")
|
||||
assertThat(warning.unsupportedParams).containsEntry("bar", "2")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `memo on network with memo support is not unsupported`() {
|
||||
val xrpCoin = buildCoin("XRP", "XRP", "XRP", decimals = 6, extrasType = Network.TransactionExtrasType.DESTINATION_TAG)
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ripple:rAddress?dt=12345",
|
||||
coins = listOf(xrpCoin),
|
||||
allCurrencies = listOf(xrpCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.memo).isEqualTo("12345")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only known params returns Success`() {
|
||||
val result = parser.parse(
|
||||
qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5&label=Satoshi",
|
||||
coins = listOf(bitcoinCoin),
|
||||
allCurrencies = listOf(bitcoinCoin),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.memo).isEqualTo("TestMemo")
|
||||
assertThat(result!!.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -209,23 +292,33 @@ internal class Bip321PaymentUriParserTest {
|
|||
// region Helpers
|
||||
|
||||
private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? {
|
||||
return (this as? PaymentUriParser.ParseResult.Success)?.content
|
||||
return when (this) {
|
||||
is PaymentUriParser.ParseResult.Success -> content
|
||||
is PaymentUriParser.ParseResult.SuccessWithWarning -> content
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private val bitcoinCoin = buildCoin("bitcoin", decimals = 8)
|
||||
private val litecoinCoin = buildCoin("litecoin", decimals = 8)
|
||||
private val ethereumCoin = buildCoin("ethereum", decimals = 18)
|
||||
private val bitcoinCoin = buildCoin("BTC", "Bitcoin", "BTC", decimals = 8)
|
||||
private val litecoinCoin = buildCoin("LTC", "Litecoin", "LTC", decimals = 8)
|
||||
private val dogecoinCoin = buildCoin("DOGE", "Dogecoin", "DOGE", decimals = 8)
|
||||
|
||||
private fun buildCoin(rawNetworkId: String, decimals: Int): CryptoCurrency.Coin {
|
||||
private fun buildCoin(
|
||||
rawNetworkId: String,
|
||||
name: String,
|
||||
symbol: String,
|
||||
decimals: Int,
|
||||
extrasType: Network.TransactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
): CryptoCurrency.Coin {
|
||||
return CryptoCurrency.Coin(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
|
||||
),
|
||||
network = buildNetwork(rawNetworkId),
|
||||
name = rawNetworkId,
|
||||
symbol = rawNetworkId.take(3).uppercase(),
|
||||
network = buildNetwork(rawNetworkId, name, symbol, extrasType),
|
||||
name = name,
|
||||
symbol = symbol,
|
||||
decimals = decimals,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
|
|
@ -239,7 +332,7 @@ internal class Bip321PaymentUriParserTest {
|
|||
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID(contractAddress),
|
||||
),
|
||||
network = buildNetwork(rawNetworkId),
|
||||
network = buildNetwork(rawNetworkId, rawNetworkId, symbol),
|
||||
name = symbol,
|
||||
symbol = symbol,
|
||||
decimals = 6,
|
||||
|
|
@ -249,18 +342,23 @@ internal class Bip321PaymentUriParserTest {
|
|||
)
|
||||
}
|
||||
|
||||
private fun buildNetwork(rawNetworkId: String): Network {
|
||||
private fun buildNetwork(
|
||||
rawNetworkId: String,
|
||||
name: String,
|
||||
symbol: String,
|
||||
extrasType: Network.TransactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
): Network {
|
||||
return Network(
|
||||
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
|
||||
backendId = rawNetworkId,
|
||||
name = rawNetworkId,
|
||||
currencySymbol = rawNetworkId.take(3).uppercase(),
|
||||
name = name,
|
||||
currencySymbol = symbol,
|
||||
derivationPath = Network.DerivationPath.None,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = false,
|
||||
canHandleTokens = false,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
transactionExtrasType = extrasType,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ import java.math.BigDecimal
|
|||
internal class Eip681PaymentUriParserTest {
|
||||
|
||||
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
|
||||
every { getShareSchemes(any()) } returns emptyList()
|
||||
every { getChainId(any()) } returns null
|
||||
every { getBlockchainNameByChainId(any()) } returns null
|
||||
every { validateAddress(any(), any()) } returns true
|
||||
}
|
||||
private val parser = Eip681PaymentUriParser(blockchainDataProvider)
|
||||
|
||||
|
|
@ -56,8 +56,8 @@ internal class Eip681PaymentUriParserTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `native transfer without chain_id falls back to scheme matching`() {
|
||||
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:")
|
||||
fun `native transfer without chain_id falls back to chainId presence check`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xRecipient?value=1000000000000000000",
|
||||
|
|
@ -174,6 +174,24 @@ internal class Eip681PaymentUriParserTest {
|
|||
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ERC-20 transfer with invalid recipient address returns Unrecognized error`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
every { blockchainDataProvider.validateAddress(any(), eq("0xInvalidRecipient")) } returns false
|
||||
|
||||
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xInvalidRecipient&uint256=1000000",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin, usdcToken),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.RecognizedError::class.java)
|
||||
val error = (result as PaymentUriParser.ParseResult.RecognizedError).error
|
||||
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
@Test
|
||||
|
|
@ -265,10 +283,64 @@ internal class Eip681PaymentUriParserTest {
|
|||
|
||||
// endregion
|
||||
|
||||
// region Unsupported params
|
||||
|
||||
@Test
|
||||
fun `native transfer with unknown param returns SuccessWithWarning`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xRecipient@1?value=1000000000000000000&gasLimit=21000",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java)
|
||||
val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning
|
||||
assertThat(warning.content.address).isEqualTo("0xRecipient")
|
||||
assertThat(warning.unsupportedParams).containsEntry("gasLimit", "21000")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ERC-20 transfer with unknown param returns SuccessWithWarning`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xRecipient&uint256=1000000&gasPrice=20000000000",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin, usdcToken),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.SuccessWithWarning::class.java)
|
||||
val warning = result as PaymentUriParser.ParseResult.SuccessWithWarning
|
||||
assertThat(warning.unsupportedParams).containsEntry("gasPrice", "20000000000")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native transfer with only known params returns Success`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xRecipient@1?value=1000000000000000000",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin),
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(PaymentUriParser.ParseResult.Success::class.java)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Helpers
|
||||
|
||||
private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? {
|
||||
return (this as? PaymentUriParser.ParseResult.Success)?.content
|
||||
return when (this) {
|
||||
is PaymentUriParser.ParseResult.Success -> content
|
||||
is PaymentUriParser.ParseResult.SuccessWithWarning -> content
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private val bitcoinCoin = buildCoin("bitcoin", decimals = 8)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import java.math.BigDecimal
|
|||
internal class QrContentClassifierTest {
|
||||
|
||||
private val blockchainDataProvider = mockk<QrContentClassifierParser.BlockchainDataProvider> {
|
||||
every { getShareSchemes(any()) } returns emptyList()
|
||||
every { validateAddress(any(), any()) } returns false
|
||||
every { getChainId(any()) } returns null
|
||||
every { findSupportedBlockchainName(any()) } returns null
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -284,7 +284,7 @@ class GetAccountCurrencyStatusUseCaseTest {
|
|||
coEvery { supplier(supplierParams) } returns flowOf(accountStatusList)
|
||||
|
||||
// Act
|
||||
val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = null)
|
||||
val actual = useCase(userWalletId = userWalletId, currencyId = currency.id, network = currency.network)
|
||||
.let(::getEmittedValues)
|
||||
|
||||
// Assert
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ interface NetworksRepository {
|
|||
*/
|
||||
suspend fun getNetworkAddresses(userWalletId: UserWalletId, network: Network.RawID): List<CryptoCurrencyAddress>
|
||||
|
||||
/**
|
||||
* Returns the default address for the given [network] in the selected [userWalletId]
|
||||
*/
|
||||
suspend fun getDefaultAddress(userWalletId: UserWalletId, network: Network): String?
|
||||
|
||||
/** Checks if there are cached statuses for given [userWalletId] */
|
||||
suspend fun hasCachedStatuses(userWalletId: UserWalletId): Boolean
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import com.tangem.domain.promo.models.StoryContent
|
|||
import com.tangem.domain.promo.models.StoryContentIds
|
||||
import com.tangem.domain.settings.repositories.SettingsRepository
|
||||
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
|
@ -39,12 +40,14 @@ class GetStoryContentUseCase(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun isFCAAllowed(id: String): Flow<Boolean> {
|
||||
return if (id == StoryContentIds.STORY_FIRST_TIME_SWAP.id) {
|
||||
settingsRepository.getUserCountryCode()
|
||||
.filterNotNull()
|
||||
.timeout(5.seconds)
|
||||
.timeout(3.seconds)
|
||||
.map { !it.needApplyFCARestrictions() }
|
||||
.catch { emit(true) }
|
||||
} else {
|
||||
flowOf(true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ dependencies {
|
|||
api(projects.domain.models)
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.networks)
|
||||
implementation(projects.domain.qrScanning.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ sealed interface ClassifiedQrContent {
|
|||
val matchingCurrencies: List<CryptoCurrency>,
|
||||
) : ClassifiedQrContent
|
||||
|
||||
data class PaymentUriWarning(
|
||||
val paymentUri: PaymentUri,
|
||||
val unsupportedParams: Map<String, String>,
|
||||
) : ClassifiedQrContent
|
||||
|
||||
sealed interface Error : ClassifiedQrContent {
|
||||
|
||||
/** QR code not recognized by any parser */
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ sealed class QrSendTarget {
|
|||
)
|
||||
}
|
||||
|
||||
data object AddressSameAsWallet : QrSendTarget()
|
||||
|
||||
data class Warning(
|
||||
val target: QrSendTarget,
|
||||
val unsupportedParams: Map<String, String>,
|
||||
) : QrSendTarget()
|
||||
|
||||
data class WalletConnect(val uri: String) : QrSendTarget()
|
||||
|
||||
data class Error(val error: ClassifiedQrContent.Error) : QrSendTarget()
|
||||
|
|
|
|||
|
|
@ -5,17 +5,23 @@ import com.tangem.domain.models.account.Account
|
|||
import com.tangem.domain.models.account.AccountId
|
||||
import com.tangem.domain.models.account.AccountName
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.networks.repository.NetworksRepository
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import java.math.BigDecimal
|
||||
import com.tangem.domain.qrscanning.models.QrSendTarget
|
||||
import kotlinx.coroutines.awaitAll
|
||||
|
||||
class ResolveQrSendTargetsUseCase(
|
||||
private val multiAccountListSupplier: MultiAccountListSupplier,
|
||||
private val qrScanningEventsRepository: QrScanningEventsRepository,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val networksRepository: NetworksRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(qrCode: String): QrSendTarget {
|
||||
|
|
@ -49,7 +55,7 @@ class ResolveQrSendTargetsUseCase(
|
|||
return resolve(classified, portfolioIndex)
|
||||
}
|
||||
|
||||
private fun resolve(classified: ClassifiedQrContent, portfolioIndex: PortfolioIndex): QrSendTarget {
|
||||
private suspend fun resolve(classified: ClassifiedQrContent, portfolioIndex: PortfolioIndex): QrSendTarget {
|
||||
return when (classified) {
|
||||
is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri)
|
||||
is ClassifiedQrContent.Error -> QrSendTarget.Error(classified)
|
||||
|
|
@ -67,17 +73,29 @@ class ResolveQrSendTargetsUseCase(
|
|||
matchingCurrencies = classified.matchingCurrencies,
|
||||
portfolioIndex = portfolioIndex,
|
||||
)
|
||||
is ClassifiedQrContent.PaymentUriWarning -> {
|
||||
val inner = resolve(classified.paymentUri, portfolioIndex)
|
||||
QrSendTarget.Warning(
|
||||
target = inner,
|
||||
unsupportedParams = classified.unsupportedParams,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveAddressTarget(
|
||||
private suspend fun resolveAddressTarget(
|
||||
address: String,
|
||||
amount: BigDecimal?,
|
||||
memo: String?,
|
||||
matchingCurrencies: List<CryptoCurrency>,
|
||||
portfolioIndex: PortfolioIndex,
|
||||
): QrSendTarget {
|
||||
val walletGroups = buildWalletGroups(matchingCurrencies, portfolioIndex)
|
||||
val ownAddressNetworks = findOwnAddressNetworks(address, matchingCurrencies, portfolioIndex)
|
||||
val walletGroups = buildWalletGroups(matchingCurrencies, portfolioIndex, ownAddressNetworks)
|
||||
|
||||
if (walletGroups.isEmpty()) {
|
||||
return QrSendTarget.AddressSameAsWallet
|
||||
}
|
||||
|
||||
val singleGroup = walletGroups.singleOrNull()
|
||||
val singleCurrency = singleGroup?.accounts?.singleOrNull()?.currencies?.singleOrNull()
|
||||
|
|
@ -100,9 +118,33 @@ class ResolveQrSendTargetsUseCase(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun findOwnAddressNetworks(
|
||||
address: String,
|
||||
matchingCurrencies: List<CryptoCurrency>,
|
||||
portfolioIndex: PortfolioIndex,
|
||||
): Map<UserWalletId, List<Network.ID>> = coroutineScope {
|
||||
matchingCurrencies.distinctBy { it.id }
|
||||
.flatMap { currency ->
|
||||
portfolioIndex.currencyLocations[currency.id].orEmpty().map {
|
||||
it.accountId.userWalletId to currency.network
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
.map { (walletId, network) ->
|
||||
async {
|
||||
val ownAddress = networksRepository.getDefaultAddress(walletId, network)
|
||||
if (ownAddress == address) walletId to network else null
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
.filterNotNull()
|
||||
.groupBy(keySelector = { it.first }, valueTransform = { it.second.id })
|
||||
}
|
||||
|
||||
private fun buildWalletGroups(
|
||||
matchingCurrencies: List<CryptoCurrency>,
|
||||
portfolioIndex: PortfolioIndex,
|
||||
ownAddressNetworks: Map<UserWalletId, List<Network.ID>>,
|
||||
): List<QrSendTarget.Multiple.WalletGroup> {
|
||||
val walletMap = linkedMapOf<UserWalletId, WalletInfo>()
|
||||
val uniqueCurrencies = matchingCurrencies.distinctBy { it.id }
|
||||
|
|
@ -110,13 +152,18 @@ class ResolveQrSendTargetsUseCase(
|
|||
for (currency in uniqueCurrencies) {
|
||||
val locations = portfolioIndex.currencyLocations[currency.id].orEmpty()
|
||||
for (location in locations) {
|
||||
val walletInfo = walletMap.getOrPut(location.accountId.userWalletId) {
|
||||
WalletInfo(location.walletName, linkedMapOf())
|
||||
val walletId = location.accountId.userWalletId
|
||||
val isOwnAddress = ownAddressNetworks[walletId]?.contains(currency.network.id) == true
|
||||
|
||||
if (!isOwnAddress) {
|
||||
val walletInfo = walletMap.getOrPut(walletId) {
|
||||
WalletInfo(location.walletName, linkedMapOf())
|
||||
}
|
||||
val accountInfo = walletInfo.accounts.getOrPut(location.accountId) {
|
||||
AccountInfo(location.accountName, mutableListOf())
|
||||
}
|
||||
accountInfo.currencies.add(currency)
|
||||
}
|
||||
val accountInfo = walletInfo.accounts.getOrPut(location.accountId) {
|
||||
AccountInfo(location.accountName, mutableListOf())
|
||||
}
|
||||
accountInfo.currencies.add(currency)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,9 @@ import com.tangem.feature.swap.domain.models.SwapAmount
|
|||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.feature.swap.models.SwapAlertUM
|
||||
import com.tangem.feature.swap.models.SwapCardState
|
||||
import com.tangem.feature.swap.models.SwapStateHolder
|
||||
import com.tangem.feature.swap.models.TransactionCardType
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.feature.swap.router.SwapNavScreen
|
||||
|
|
@ -215,7 +217,7 @@ internal class SwapModel @Inject constructor(
|
|||
val feeSelectorRepository = FeeSelectorRepository()
|
||||
|
||||
// shows currency order (direct - swap initial to selected, reversed = selected to initial)
|
||||
private var isOrderReversed by mutableStateOf(false)
|
||||
private val isOrderReversed = MutableStateFlow(value = params.isInitialReverseOrder)
|
||||
private val lastAmount = mutableStateOf(INITIAL_AMOUNT)
|
||||
private val lastReducedBalanceBy = mutableStateOf(BigDecimal.ZERO)
|
||||
private val swapRouter: SwapRouter = SwapRouter(router = router)
|
||||
|
|
@ -427,20 +429,22 @@ internal class SwapModel @Inject constructor(
|
|||
selectedAccountCurrency?.status to selectedAccountCurrency?.account
|
||||
}
|
||||
|
||||
applyInitialTokenChoice(
|
||||
val isApplied = applyInitialTokenChoice(
|
||||
state = state,
|
||||
selectedCurrency = selectedCurrency,
|
||||
selectedAccount = selectedAccount,
|
||||
isReverseFromTo = isReverseFromTo,
|
||||
)
|
||||
|
||||
val fromCryptoCurrency = if (isOrderReversed) {
|
||||
dataState.toCryptoCurrency
|
||||
} else {
|
||||
dataState.fromCryptoCurrency
|
||||
}
|
||||
// assume that fromCryptoCurrency selected according reverse flag,
|
||||
// so update fee paid currency according to it
|
||||
val fromCryptoCurrency = dataState.fromCryptoCurrency
|
||||
|
||||
if (fromCryptoCurrency != null) {
|
||||
if (isApplied && fromCryptoCurrency != null) {
|
||||
TangemLogger.i(
|
||||
"updateFeePaidCryptoCurrencyFor: id = ${fromCryptoCurrency.currency.id}, " +
|
||||
"isReverseFromTo: $isReverseFromTo",
|
||||
)
|
||||
updateFeePaidCryptoCurrencyFor(fromCryptoCurrency)
|
||||
} else {
|
||||
TangemLogger.e("updateFeePaidCryptoCurrencyFor failed: fromCryptoCurrency is null")
|
||||
|
|
@ -498,7 +502,7 @@ internal class SwapModel @Inject constructor(
|
|||
state = state,
|
||||
selectedCurrency = selectedCurrency,
|
||||
selectedAccount = selectedAccount,
|
||||
isReverseFromTo = isOrderReversed,
|
||||
isReverseFromTo = isOrderReversed.value,
|
||||
)
|
||||
|
||||
subscribeToCoinBalanceUpdatesIfNeeded()
|
||||
|
|
@ -527,29 +531,34 @@ internal class SwapModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* returns true if tokens are selected and dataState is updated,
|
||||
* false if selected token is null and alert is shown with error message
|
||||
*/
|
||||
private fun applyInitialTokenChoice(
|
||||
state: TokensDataStateExpress,
|
||||
selectedCurrency: CryptoCurrencyStatus?,
|
||||
selectedAccount: Account.CryptoPortfolio?,
|
||||
isReverseFromTo: Boolean,
|
||||
) {
|
||||
): Boolean {
|
||||
// exceptional case
|
||||
if (selectedCurrency == null) {
|
||||
TangemLogger.e("No available tokens to swap for ${initialCurrencyFrom.symbol}")
|
||||
analyticsEventHandler.send(SwapEvents.NoticeNoAvailableTokensToSwap())
|
||||
uiState = stateBuilder.createNoAvailableTokensToSwapState(
|
||||
uiStateHolder = uiState,
|
||||
fromToken = initialFromStatus,
|
||||
)
|
||||
return
|
||||
return false
|
||||
}
|
||||
isOrderReversed = isReverseFromTo
|
||||
val (fromCurrencyStatus, toCurrencyStatus) = if (isOrderReversed) {
|
||||
isOrderReversed.value = isReverseFromTo
|
||||
val (fromCurrencyStatus, toCurrencyStatus) = if (isOrderReversed.value) {
|
||||
selectedCurrency to initialFromStatus
|
||||
} else {
|
||||
initialFromStatus to selectedCurrency
|
||||
}
|
||||
val (fromAccount, toAccount) = if (canUseFromAccountCurrencyStatus) {
|
||||
if (isOrderReversed) {
|
||||
if (isOrderReversed.value) {
|
||||
selectedAccount to requireNotNull(fromAccountCurrencyStatus).account
|
||||
} else {
|
||||
requireNotNull(fromAccountCurrencyStatus).account to selectedAccount
|
||||
|
|
@ -573,7 +582,7 @@ internal class SwapModel @Inject constructor(
|
|||
toAccount = toAccount,
|
||||
)
|
||||
) {
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
startLoadingQuotes(
|
||||
|
|
@ -585,10 +594,11 @@ internal class SwapModel @Inject constructor(
|
|||
reduceBalanceBy = lastReducedBalanceBy.value,
|
||||
toProvidersList = findSwapProviders(fromCurrencyStatus, toCurrencyStatus),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updateTokensState(tokenDataState: TokensDataStateExpress) {
|
||||
val tokensDataState = if (isOrderReversed) tokenDataState.fromGroup else tokenDataState.toGroup
|
||||
val tokensDataState = if (isOrderReversed.value) tokenDataState.fromGroup else tokenDataState.toGroup
|
||||
chooseTokenBridge.updateCurrenciesGroup(tokensDataState)
|
||||
}
|
||||
|
||||
|
|
@ -1162,7 +1172,7 @@ internal class SwapModel @Inject constructor(
|
|||
|
||||
private fun onSearchEntered(searchQuery: String) {
|
||||
val tokenDataState = dataState.tokensDataState ?: return
|
||||
val group = if (isOrderReversed) {
|
||||
val group = if (isOrderReversed.value) {
|
||||
tokenDataState.fromGroup
|
||||
} else {
|
||||
tokenDataState.toGroup
|
||||
|
|
@ -1192,7 +1202,7 @@ internal class SwapModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
val filteredTokenDataState = if (isOrderReversed) {
|
||||
val filteredTokenDataState = if (isOrderReversed.value) {
|
||||
tokenDataState.copy(
|
||||
fromGroup = tokenDataState.fromGroup.copy(
|
||||
available = available,
|
||||
|
|
@ -1238,7 +1248,7 @@ internal class SwapModel @Inject constructor(
|
|||
val fromAccount: Account.CryptoPortfolio?
|
||||
val toToken: CryptoCurrencyStatus
|
||||
val toAccount: Account.CryptoPortfolio?
|
||||
if (isOrderReversed) {
|
||||
if (isOrderReversed.value) {
|
||||
fromToken = foundToken
|
||||
fromAccount = foundAccount
|
||||
toToken = initialFromStatus
|
||||
|
|
@ -1295,6 +1305,17 @@ internal class SwapModel @Inject constructor(
|
|||
return
|
||||
}
|
||||
modelScope.launch {
|
||||
TangemLogger.i(
|
||||
"updateFeePaidCryptoCurrencyFor: id = ${fromToken.currency.id}, " +
|
||||
"isOrderReversed: ${isOrderReversed.value}",
|
||||
)
|
||||
if ((uiState.sendCardData as? SwapCardState.SwapCardData)?.type is TransactionCardType.ReadOnly) {
|
||||
uiState = stateBuilder.createInitialLoadingState(
|
||||
initialCurrencyFrom = fromToken.currency,
|
||||
initialCurrencyTo = toToken.currency,
|
||||
fromNetworkInfo = fromToken.currency.getNetworkInfo(),
|
||||
)
|
||||
}
|
||||
updateFeePaidCryptoCurrencyFor(fromToken)
|
||||
startLoadingQuotes(
|
||||
fromToken = fromToken,
|
||||
|
|
@ -1314,7 +1335,7 @@ internal class SwapModel @Inject constructor(
|
|||
tokens: TokensDataStateExpress,
|
||||
id: String,
|
||||
): Pair<CryptoCurrencyStatus?, Account.CryptoPortfolio?> {
|
||||
val accountCryptoCurrencyStatus = if (isOrderReversed) {
|
||||
val accountCryptoCurrencyStatus = if (isOrderReversed.value) {
|
||||
tokens.fromGroup
|
||||
} else {
|
||||
tokens.toGroup
|
||||
|
|
@ -1346,7 +1367,23 @@ internal class SwapModel @Inject constructor(
|
|||
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencyStatus = currencyStatus,
|
||||
).getOrNull() ?: currencyStatus,
|
||||
)
|
||||
.onLeft {
|
||||
TangemLogger.e(
|
||||
"Coin balance: Unable to get fee paid crypto currency status for " +
|
||||
"${currencyStatus.currency.id}",
|
||||
)
|
||||
}
|
||||
.onRight { status ->
|
||||
if (status == null) {
|
||||
TangemLogger.e(
|
||||
"Coin balance: Fee paid crypto currency status is null " +
|
||||
"for ${currencyStatus.currency.id}",
|
||||
)
|
||||
}
|
||||
}
|
||||
.getOrNull()
|
||||
?: currencyStatus,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1392,7 +1429,11 @@ internal class SwapModel @Inject constructor(
|
|||
toCryptoCurrency = newToToken,
|
||||
toAccount = newToAccount,
|
||||
)
|
||||
isOrderReversed = !isOrderReversed
|
||||
isOrderReversed.value = !isOrderReversed.value
|
||||
TangemLogger.i(
|
||||
"updateFeePaidCryptoCurrencyFor: id = ${newFromToken.currency.id}, " +
|
||||
"isOrderReversed: ${isOrderReversed.value}",
|
||||
)
|
||||
updateFeePaidCryptoCurrencyFor(newFromToken)
|
||||
dataState.tokensDataState?.let { tokensDataState ->
|
||||
updateTokensState(tokensDataState)
|
||||
|
|
@ -1824,13 +1865,13 @@ internal class SwapModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun findSwapProviders(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): List<SwapProvider> {
|
||||
val groupToFind = if (isOrderReversed) {
|
||||
val groupToFind = if (isOrderReversed.value) {
|
||||
dataState.tokensDataState?.fromGroup
|
||||
} else {
|
||||
dataState.tokensDataState?.toGroup
|
||||
} ?: return emptyList()
|
||||
|
||||
val idToFind = if (isOrderReversed) {
|
||||
val idToFind = if (isOrderReversed.value) {
|
||||
fromToken.currency.id.value
|
||||
} else {
|
||||
toToken.currency.id.value
|
||||
|
|
@ -1856,8 +1897,8 @@ internal class SwapModel @Inject constructor(
|
|||
fromAccount: Account.CryptoPortfolio?,
|
||||
toAccount: Account.CryptoPortfolio?,
|
||||
): Boolean {
|
||||
val selectedCurrency = if (isOrderReversed) fromToken else toToken
|
||||
if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed)) return false
|
||||
val selectedCurrency = if (isOrderReversed.value) fromToken else toToken
|
||||
if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed.value)) return false
|
||||
|
||||
analyticsEventHandler.send(
|
||||
SwapEvents.NoticeUnavailableToSwapPair(
|
||||
|
|
@ -1867,6 +1908,14 @@ internal class SwapModel @Inject constructor(
|
|||
receiveBlockchain = toToken.currency.network.name,
|
||||
),
|
||||
)
|
||||
// Cancel periodic quote task if selected token is not supported
|
||||
singleTaskScheduler.cancelTask()
|
||||
// Reset data state
|
||||
dataState = SwapProcessDataState(
|
||||
tokensDataState = dataState.tokensDataState,
|
||||
)
|
||||
lastReducedBalanceBy.value = BigDecimal.ZERO
|
||||
lastAmount.value = INITIAL_AMOUNT
|
||||
uiState = stateBuilder.createSwapNotSupportedState(
|
||||
uiStateHolder = uiState,
|
||||
fromToken = fromToken,
|
||||
|
|
@ -1917,13 +1966,13 @@ internal class SwapModel @Inject constructor(
|
|||
val from = dataState.fromCryptoCurrency ?: return false
|
||||
val to = dataState.toCryptoCurrency ?: return false
|
||||
|
||||
val currenciesGroup = if (isOrderReversed) {
|
||||
val currenciesGroup = if (isOrderReversed.value) {
|
||||
dataState.tokensDataState?.toGroup
|
||||
} else {
|
||||
dataState.tokensDataState?.fromGroup
|
||||
} ?: return false
|
||||
|
||||
val chosen = if (isOrderReversed) from else to
|
||||
val chosen = if (isOrderReversed.value) from else to
|
||||
|
||||
return currenciesGroup.accountCurrencyList.flatMap { accountSwapAvailability ->
|
||||
accountSwapAvailability.currencyList.map { accountSwapCurrency ->
|
||||
|
|
|
|||
|
|
@ -183,10 +183,12 @@ internal class StateBuilder(
|
|||
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
return uiStateHolder.copy(
|
||||
sendCardData = SwapCardState.SwapCardData(
|
||||
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable).copy(
|
||||
type = TransactionCardType.ReadOnly(
|
||||
accountTitleUM = getFromCardAccountTitle(fromAccount),
|
||||
),
|
||||
amountTextFieldValue = null,
|
||||
amountTextFieldValue = TextFieldValue(
|
||||
text = "0",
|
||||
),
|
||||
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
|
||||
token = fromToken,
|
||||
tokenIconUrl = fromToken.currency.iconUrl,
|
||||
|
|
|
|||
|
|
@ -771,7 +771,12 @@ internal class WalletModel @Inject constructor(
|
|||
}
|
||||
|
||||
private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) {
|
||||
when (val target = resolveQrSendTargetsUseCase(qrCode)) {
|
||||
val target = resolveQrSendTargetsUseCase(qrCode)
|
||||
handleQrTarget(target, resultSource)
|
||||
}
|
||||
|
||||
private fun handleQrTarget(target: QrSendTarget, resultSource: QrResultSource) {
|
||||
when (target) {
|
||||
is QrSendTarget.WalletConnect -> {
|
||||
val source = when (resultSource) {
|
||||
QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD
|
||||
|
|
@ -801,6 +806,17 @@ internal class WalletModel @Inject constructor(
|
|||
is QrSendTarget.Multiple -> {
|
||||
innerWalletRouter.openNetworkSelectionBottomSheet(target)
|
||||
}
|
||||
is QrSendTarget.AddressSameAsWallet -> {
|
||||
uiMessageSender.send(WalletAlertUM.qrCodeAddressSameAsWallet())
|
||||
}
|
||||
is QrSendTarget.Warning -> {
|
||||
uiMessageSender.send(
|
||||
WalletAlertUM.qrCodeUnsupportedParams(
|
||||
unsupportedParams = target.unsupportedParams,
|
||||
onContinue = { handleQrTarget(target.target, resultSource) },
|
||||
),
|
||||
)
|
||||
}
|
||||
is QrSendTarget.Error -> handleQrError(target.error)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ internal object WalletScreenPreviewData {
|
|||
isHidingMode = false,
|
||||
showMarketsOnboarding = false,
|
||||
onDismissMarketsTooltip = {},
|
||||
isRedesignEnabled = true,
|
||||
)
|
||||
|
||||
val defaultAccountState = defaultState.copy(
|
||||
|
|
|
|||
|
|
@ -248,6 +248,7 @@ internal object WalletScreenPreviewDataLegacy {
|
|||
isHidingMode = false,
|
||||
showMarketsOnboarding = false,
|
||||
onDismissMarketsTooltip = {},
|
||||
isRedesignEnabled = false,
|
||||
)
|
||||
|
||||
internal val accountScreenState =
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ internal class MultiWalletContentLoader @AssistedInject constructor(
|
|||
private val multiWalletActionButtonsSubscriberFactory: MultiWalletActionButtonsSubscriber.Factory,
|
||||
private val tangemPayMainSubscriberFactory: TangemPayMainSubscriber.Factory,
|
||||
private val tokenSyncSubscriberFactory: TokenSyncSubscriber.Factory,
|
||||
private val tokenListAnalyticsSubscriberFactory: TokenListAnalyticsSubscriber.Factory,
|
||||
private val designFeatureToggles: DesignFeatureToggles,
|
||||
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
|
||||
) : WalletContentLoader(id = userWallet.walletId) {
|
||||
|
|
@ -34,6 +35,7 @@ internal class MultiWalletContentLoader @AssistedInject constructor(
|
|||
},
|
||||
multiWalletActionButtonsSubscriberFactory.create(userWallet),
|
||||
tangemPayMainSubscriberFactory.create(userWallet),
|
||||
tokenListAnalyticsSubscriberFactory.create(userWallet),
|
||||
if (hotWalletFeatureToggles.isTokenSyncEnabled && userWallet is UserWallet.Hot) {
|
||||
tokenSyncSubscriberFactory.create(userWallet)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ internal class WalletStateController @Inject constructor(
|
|||
isHidingMode = false,
|
||||
showMarketsOnboarding = false,
|
||||
onDismissMarketsTooltip = {},
|
||||
isRedesignEnabled = designFeatureToggles.isRedesignEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,30 @@ internal object WalletAlertUM {
|
|||
)
|
||||
}
|
||||
|
||||
fun qrCodeUnsupportedParams(unsupportedParams: Map<String, String>, onContinue: () -> Unit): DialogMessage {
|
||||
val paramsList = unsupportedParams.entries.joinToString { "${it.key} = ${it.value}" }
|
||||
return DialogMessage(
|
||||
title = resourceReference(R.string.qr_scanner_warning_unknown_parameters_title),
|
||||
message = resourceReference(
|
||||
id = R.string.qr_scanner_warning_unknown_parameters_message,
|
||||
formatArgs = WrappedList(listOf(paramsList)),
|
||||
),
|
||||
firstActionBuilder = {
|
||||
EventMessageAction(
|
||||
title = resourceReference(R.string.common_continue),
|
||||
onClick = onContinue,
|
||||
)
|
||||
},
|
||||
secondActionBuilder = { cancelAction() },
|
||||
)
|
||||
}
|
||||
|
||||
fun qrCodeAddressSameAsWallet(): DialogMessage {
|
||||
return DialogMessage(
|
||||
message = resourceReference(R.string.send_error_address_same_as_wallet),
|
||||
)
|
||||
}
|
||||
|
||||
fun confirmExpressStatusHide(onConfirmClick: () -> Unit): DialogMessage {
|
||||
return DialogMessage(
|
||||
title = resourceReference(R.string.express_status_hide_dialog_title),
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ internal data class WalletScreenState(
|
|||
val isHidingMode: Boolean,
|
||||
val showMarketsOnboarding: Boolean,
|
||||
val onDismissMarketsTooltip: () -> Unit,
|
||||
val isRedesignEnabled: Boolean,
|
||||
)
|
||||
|
|
@ -15,17 +15,22 @@ internal abstract class WalletStateTransformer(
|
|||
abstract fun transform(walletUM: WalletUM): WalletUM
|
||||
|
||||
final override fun transform(prevState: WalletScreenState): WalletScreenState {
|
||||
return prevState.copy(
|
||||
wallets = prevState.wallets
|
||||
.map { state ->
|
||||
if (state.walletCardState.id == userWalletId) transform(state) else state
|
||||
}
|
||||
.toImmutableList(),
|
||||
wallets2 = prevState.wallets2
|
||||
.map { walletUM ->
|
||||
if (walletUM.walletsBalanceUM.id == userWalletId) transform(walletUM) else walletUM
|
||||
}
|
||||
.toImmutableList(),
|
||||
)
|
||||
return if (prevState.isRedesignEnabled) {
|
||||
prevState.copy(
|
||||
wallets2 = prevState.wallets2
|
||||
.map { walletUM ->
|
||||
if (walletUM.walletsBalanceUM.id == userWalletId) transform(walletUM) else walletUM
|
||||
}
|
||||
.toImmutableList(),
|
||||
)
|
||||
} else {
|
||||
prevState.copy(
|
||||
wallets = prevState.wallets
|
||||
.map { state ->
|
||||
if (state.walletCardState.id == userWalletId) transform(state) else state
|
||||
}
|
||||
.toImmutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.platform.testTag
|
||||
|
|
@ -107,6 +108,7 @@ internal fun WalletTopBar(
|
|||
*
|
||||
* @param config component config
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
@Deprecated("Remove with main toggle [DesignFeatureToggles.isRedesignEnabled]")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -122,7 +124,9 @@ internal fun WalletTopBar(config: WalletTopBarConfig) {
|
|||
Icon(
|
||||
painter = painterResource(id = action.iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.testTag(MainScreenTestTags.MORE_BUTTON),
|
||||
modifier = Modifier
|
||||
.rotate(if (action.iconRes == R.drawable.ic_more_default_24) 90f else 0f)
|
||||
.testTag(MainScreenTestTags.MORE_BUTTON),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@
|
|||
# https://github.com/tangem/tangem-sdk-android/
|
||||
# https://github.com/tangem/vico
|
||||
|
||||
tangemBlockchainSdk = "develop-1461"
|
||||
tangemBlockchainSdk = "develop-1473"
|
||||
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
|
||||
tangemCardSdk = "develop-598"
|
||||
tangemCardSdk = "develop-602"
|
||||
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
|
||||
tangemVico = "tangem-master-21"
|
||||
#tangemVico = "0.0.1" # Keep it! - used for local builds ^
|
||||
tangemHotSdk = "develop-549"
|
||||
tangemHotSdk = "develop-550"
|
||||
#tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue