From 29f8becc48451c2200bc8fa65af83f072876c985 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 25 Mar 2026 17:31:54 +0500 Subject: [PATCH 01/19] Updated on 2026-08-14 --- .../com/tangem/core/ui/components/transactions/Transaction.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt index 884b8bfdae..3b3d6047d0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt @@ -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 -> { From 041ff02106666d86ac026ed244cae4cb9f89bac0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 25 Mar 2026 15:42:38 +0300 Subject: [PATCH 02/19] Updated on 2026-08-14 --- .../qrscanning/di/QrScanningDataModule.kt | 6 +- .../parser/Bip321PaymentUriParser.kt | 45 ++-- .../parser/Eip681PaymentUriParser.kt | 12 +- .../parser/PaymentUriResolveHelper.kt | 84 +++++++ .../parser/QrContentClassifierParser.kt | 5 - .../data/qrscanning/parser/QrSentUriParser.kt | 3 +- .../parser/SolanaPaymentUriParser.kt | 55 +++++ .../qrscanning/parser/TronPaymentUriParser.kt | 73 ++++++ .../qrscanning/Bip321PaymentUriParserTest.kt | 98 ++++---- .../qrscanning/Eip681PaymentUriParserTest.kt | 5 +- .../qrscanning/QrContentClassifierTest.kt | 1 - .../qrscanning/SolanaPaymentUriParserTest.kt | 182 ++++++++++++++ .../qrscanning/TronPaymentUriParserTest.kt | 222 ++++++++++++++++++ 13 files changed, 706 insertions(+), 85 deletions(-) create mode 100644 data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriResolveHelper.kt create mode 100644 data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt create mode 100644 data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt create mode 100644 data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt create mode 100644 data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt index 62ff974e39..b824e19a4e 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt @@ -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,7 +26,9 @@ internal object QrScanningDataModule { blockchainDataProvider = blockchainDataProvider, paymentUriParsers = setOf( Eip681PaymentUriParser(blockchainDataProvider), - Bip321PaymentUriParser(blockchainDataProvider), + TronPaymentUriParser(), + SolanaPaymentUriParser(), + Bip321PaymentUriParser(), ), ), ) diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt index 2b8dc3085f..ba281b2a7f 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt @@ -1,10 +1,12 @@ 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,22 +14,24 @@ internal class Bip321PaymentUriParser( coins: List, allCurrencies: List, ): 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, ), ) } @@ -42,22 +46,19 @@ internal class Bip321PaymentUriParser( ) } - private fun extractSchemeAndRest( - qrCode: String, - coins: List, - ): Pair, 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 { + val BLOCKCHAINS = listOf( + Blockchain.Bitcoin, + Blockchain.BitcoinTestnet, + Blockchain.Litecoin, + Blockchain.Binance, + Blockchain.BinanceTestnet, + Blockchain.Dogecoin, + Blockchain.XRP, + ) + val SCHEME_TO_BLOCKCHAINS: Map> = BLOCKCHAINS + .flatMap { blockchain -> blockchain.getShareScheme().map { scheme -> scheme to blockchain } } + .groupBy({ it.first }, { it.second }) + .mapValues { it.value.toSet() } } } \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt index 2d109a40c8..1a0fa0caac 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt @@ -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, allCurrencies: List, ): 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) @@ -114,7 +114,7 @@ internal class Eip681PaymentUriParser( private fun findMatchingCoins(chainId: Long?, coins: List): List { if (chainId == null) { return coins.filter { coin -> - blockchainDataProvider.getShareSchemes(coin.network).any { it.startsWith(SCHEME) } + blockchainDataProvider.getChainId(coin.network) != null } } return coins.filter { coin -> @@ -164,7 +164,7 @@ internal class Eip681PaymentUriParser( private companion object { // ethereum:
[@][/][?] 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" diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriResolveHelper.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriResolveHelper.kt new file mode 100644 index 0000000000..c28f09b726 --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriResolveHelper.kt @@ -0,0 +1,84 @@ +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() + .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, + 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, + matchingCurrencies = matchingCurrencies, + ), + ) + } + + data class ResolveContext( + val parsed: QrSentUriParser.Result, + val matchingCoins: List, + val matchingNetworkIds: Set, + val allCurrencies: List, + val blockchainName: String, + val qrCode: String, + ) +} \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt index dc0ef5a3db..3f8817247d 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt @@ -77,7 +77,6 @@ internal class QrContentClassifierParser( } internal interface BlockchainDataProvider { - fun getShareSchemes(network: Network): List fun validateAddress(network: Network, address: String): Boolean fun getChainId(network: Network): Long? fun findSupportedBlockchainName(address: String): String? @@ -85,10 +84,6 @@ internal class QrContentClassifierParser( } internal class DefaultBlockchainDataProvider : BlockchainDataProvider { - override fun getShareSchemes(network: Network): List { - return runCatching { network.toBlockchain().getShareScheme() }.getOrDefault(emptyList()) - } - override fun validateAddress(network: Network, address: String): Boolean { return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false) } diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt index ea80fb564d..af64a3c485 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt @@ -20,7 +20,7 @@ internal class QrSentUriParser { val params = extractParameters(withoutScheme) val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull() - val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let { + val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE] ?: params[PARAM_DT])?.let { runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it) } @@ -53,6 +53,7 @@ internal class QrSentUriParser { const val PARAM_AMOUNT = "amount" const val PARAM_MEMO = "memo" const val PARAM_MESSAGE = "message" + const val PARAM_DT = "dt" const val PARAM_ADDRESS = "address" const val PARAM_VALUE = "value" const val PARAM_UINT256 = "uint256" diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt new file mode 100644 index 0000000000..5c00d4eab8 --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt @@ -0,0 +1,55 @@ +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 helper: PaymentUriResolveHelper = PaymentUriResolveHelper(), +) : PaymentUriParser { + + override fun parse( + qrCode: String, + coins: List, + allCurrencies: List, + ): 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 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.params[PARAM_SPL_TOKEN] + return if (splTokenMint != null) { + helper.resolveTokenTransfer(context, splTokenMint) + } else { + helper.resolveNativeOrAll(context) + } + } + + private companion object { + val BLOCKCHAIN = Blockchain.Solana + val SCHEMES = BLOCKCHAIN.getShareScheme() + const val PARAM_SPL_TOKEN = "spl-token" + } +} \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt new file mode 100644 index 0000000000..61627b9738 --- /dev/null +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt @@ -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 +import java.math.BigDecimal +import java.math.MathContext + +internal class TronPaymentUriParser( + private val helper: PaymentUriResolveHelper = PaymentUriResolveHelper(), +) : PaymentUriParser { + + override fun parse( + qrCode: String, + coins: List, + allCurrencies: List, + ): 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 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.params[PARAM_TOKEN] + return if (tokenContractAddress != null) { + helper.resolveTokenTransfer(context, tokenContractAddress, ::interpretAmount) + } else { + helper.resolveNativeOrAll(context, ::interpretAmount) + } + } + + /** + * 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" + } +} \ No newline at end of file diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt index 63a91fe88e..723ea0ef8c 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt @@ -3,28 +3,20 @@ package com.tangem.data.qrscanning import com.google.common.truth.Truth.assertThat import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser import com.tangem.data.qrscanning.parser.PaymentUriParser -import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.qrscanning.models.ClassifiedQrContent -import io.mockk.every -import io.mockk.mockk import org.junit.Test import java.math.BigDecimal internal class Bip321PaymentUriParserTest { - private val blockchainDataProvider = mockk { - every { getShareSchemes(any()) } returns emptyList() - } - private val parser = Bip321PaymentUriParser(blockchainDataProvider) + private val parser = Bip321PaymentUriParser() // region Basic parsing @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 +32,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 +46,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 +60,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 +76,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 +89,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 +112,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 +136,26 @@ internal class Bip321PaymentUriParserTest { // endregion + // region Unsupported network + + @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", @@ -192,8 +195,6 @@ internal class Bip321PaymentUriParserTest { @Test fun `bitcoin URI with memo param`() { - every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:") - val result = parser.parse( qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1&memo=TestMemo", coins = listOf(bitcoinCoin), @@ -212,20 +213,25 @@ internal class Bip321PaymentUriParserTest { return (this as? PaymentUriParser.ParseResult.Success)?.content } - 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, + ): 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), + name = name, + symbol = symbol, decimals = decimals, iconUrl = null, isCustom = false, @@ -239,7 +245,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,12 +255,12 @@ internal class Bip321PaymentUriParserTest { ) } - private fun buildNetwork(rawNetworkId: String): Network { + private fun buildNetwork(rawNetworkId: String, name: String, symbol: String): 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"), diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt index 6fca241974..f43f5e513e 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt @@ -15,7 +15,6 @@ import java.math.BigDecimal internal class Eip681PaymentUriParserTest { private val blockchainDataProvider = mockk { - every { getShareSchemes(any()) } returns emptyList() every { getChainId(any()) } returns null every { getBlockchainNameByChainId(any()) } returns null } @@ -56,8 +55,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", diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt index 18f3572e4c..af88722a8d 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt @@ -14,7 +14,6 @@ import java.math.BigDecimal internal class QrContentClassifierTest { private val blockchainDataProvider = mockk { - every { getShareSchemes(any()) } returns emptyList() every { validateAddress(any(), any()) } returns false every { getChainId(any()) } returns null every { findSupportedBlockchainName(any()) } returns null diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt new file mode 100644 index 0000000000..5f2935b741 --- /dev/null +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt @@ -0,0 +1,182 @@ +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.SolanaPaymentUriParser +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import org.junit.Test +import java.math.BigDecimal + +internal class SolanaPaymentUriParserTest { + + private val parser = SolanaPaymentUriParser() + + // 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 `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 Helpers + + private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? { + return (this as? PaymentUriParser.ParseResult.Success)?.content + } + + 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 +} \ No newline at end of file diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt new file mode 100644 index 0000000000..63529bdd52 --- /dev/null +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt @@ -0,0 +1,222 @@ +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.TronPaymentUriParser +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import org.junit.Test +import java.math.BigDecimal + +internal class TronPaymentUriParserTest { + + private val parser = TronPaymentUriParser() + + // 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 `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 Helpers + + private fun PaymentUriParser.ParseResult.asSuccess(): ClassifiedQrContent.PaymentUri? { + return (this as? PaymentUriParser.ParseResult.Success)?.content + } + + 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 +} \ No newline at end of file From 307b311afdb73895bcc488a13f1488e8ed85e327 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 25 Mar 2026 15:09:26 +0300 Subject: [PATCH 03/19] Updated on 2026-08-14 --- .../tap/di/domain/QrScanningDomainModule.kt | 3 + .../repository/DefaultNetworksRepository.kt | 16 ++--- .../networks/repository/NetworksRepository.kt | 5 ++ domain/qr-scanning/build.gradle.kts | 1 + .../domain/qrscanning/models/QrSendTarget.kt | 2 + .../usecases/ResolveQrSendTargetsUseCase.kt | 58 ++++++++++++++++--- .../wallet/child/wallet/model/WalletModel.kt | 3 + .../wallet/state/model/WalletAlertUM.kt | 6 ++ 8 files changed, 77 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt index bc33cbeb88..04581dc15c 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt @@ -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, ) } } \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt b/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt index 4755e55248..0cb24be71c 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt @@ -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() - } - } } \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/repository/NetworksRepository.kt b/domain/networks/src/main/java/com/tangem/domain/networks/repository/NetworksRepository.kt index e27c6b2b47..f5641e9e9f 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/repository/NetworksRepository.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/repository/NetworksRepository.kt @@ -30,6 +30,11 @@ interface NetworksRepository { */ suspend fun getNetworkAddresses(userWalletId: UserWalletId, network: Network.RawID): List + /** + * 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 } \ No newline at end of file diff --git a/domain/qr-scanning/build.gradle.kts b/domain/qr-scanning/build.gradle.kts index 2679da7164..497c075d41 100644 --- a/domain/qr-scanning/build.gradle.kts +++ b/domain/qr-scanning/build.gradle.kts @@ -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) diff --git a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt index 7220a7ef95..dcb02f58d4 100644 --- a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt @@ -39,6 +39,8 @@ sealed class QrSendTarget { ) } + data object AddressSameAsWallet : QrSendTarget() + data class WalletConnect(val uri: String) : QrSendTarget() data class Error(val error: ClassifiedQrContent.Error) : QrSendTarget() diff --git a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt index a33e20ec2d..9353df0a2a 100644 --- a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt +++ b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt @@ -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) @@ -70,14 +76,19 @@ class ResolveQrSendTargetsUseCase( } } - private fun resolveAddressTarget( + private suspend fun resolveAddressTarget( address: String, amount: BigDecimal?, memo: String?, matchingCurrencies: List, 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 +111,33 @@ class ResolveQrSendTargetsUseCase( } } + private suspend fun findOwnAddressNetworks( + address: String, + matchingCurrencies: List, + portfolioIndex: PortfolioIndex, + ): Map> = 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, portfolioIndex: PortfolioIndex, + ownAddressNetworks: Map>, ): List { val walletMap = linkedMapOf() val uniqueCurrencies = matchingCurrencies.distinctBy { it.id } @@ -110,13 +145,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) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index e64451ff14..a65941d7ed 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -785,6 +785,9 @@ internal class WalletModel @Inject constructor( is QrSendTarget.Multiple -> { innerWalletRouter.openNetworkSelectionBottomSheet(target) } + is QrSendTarget.AddressSameAsWallet -> { + uiMessageSender.send(WalletAlertUM.qrCodeAddressSameAsWallet()) + } is QrSendTarget.Error -> handleQrError(target.error) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletAlertUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletAlertUM.kt index 665fcfdafd..9b07b7f428 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletAlertUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletAlertUM.kt @@ -94,6 +94,12 @@ internal object WalletAlertUM { ) } + 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), From 76d77bd83cdabc61a9e8f426e62a7bb5bcaed245 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 25 Mar 2026 21:25:07 +0300 Subject: [PATCH 04/19] Updated on 2026-08-14 --- .../wallet/ui/components/common/WalletTopBar.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt index 8285366fb7..c779fe25bd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletTopBar.kt @@ -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), ) } } From d46f2119fa749a4c7a753b3732a235e1316df89c Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 26 Mar 2026 15:11:44 +0500 Subject: [PATCH 05/19] Updated on 2026-08-14 --- .../common/preview/WalletScreenPreviewData.kt | 1 + .../preview/WalletScreenPreviewDataLegacy.kt | 1 + .../wallet/state/WalletStateController.kt | 1 + .../wallet/state/model/WalletScreenState.kt | 1 + .../transformers/WalletStateTransformer.kt | 29 +++++++++++-------- 5 files changed, 21 insertions(+), 12 deletions(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt index 2b44fa4727..cf9ffaf79b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt @@ -95,5 +95,6 @@ internal object WalletScreenPreviewData { isHidingMode = false, showMarketsOnboarding = false, onDismissMarketsTooltip = {}, + isRedesignEnabled = true, ) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt index 5485587450..5f2c522e24 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt @@ -245,6 +245,7 @@ internal object WalletScreenPreviewDataLegacy { isHidingMode = false, showMarketsOnboarding = false, onDismissMarketsTooltip = {}, + isRedesignEnabled = false, ) internal val accountScreenState = diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt index b1ddbd67b7..7ffd228a70 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt @@ -139,6 +139,7 @@ internal class WalletStateController @Inject constructor( isHidingMode = false, showMarketsOnboarding = false, onDismissMarketsTooltip = {}, + isRedesignEnabled = designFeatureToggles.isRedesignEnabled, ) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt index fe7cb111e8..9a0e1d8822 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt @@ -15,4 +15,5 @@ internal data class WalletScreenState( val isHidingMode: Boolean, val showMarketsOnboarding: Boolean, val onDismissMarketsTooltip: () -> Unit, + val isRedesignEnabled: Boolean, ) \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/WalletStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/WalletStateTransformer.kt index 5aa3c34900..2790e44084 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/WalletStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/WalletStateTransformer.kt @@ -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(), + ) + } } } \ No newline at end of file From c44508b705fe46ca774a0dc527e714f2f2749b13 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 25 Mar 2026 17:14:25 +0300 Subject: [PATCH 06/19] Updated on 2026-08-14 --- .../qrscanning/di/QrScanningDataModule.kt | 6 ++--- .../parser/Bip321PaymentUriParser.kt | 10 ++++++++ .../parser/Eip681PaymentUriParser.kt | 20 +++++++++++++++- .../parser/SolanaPaymentUriParser.kt | 10 ++++++++ .../qrscanning/parser/TronPaymentUriParser.kt | 10 ++++++++ .../qrscanning/Bip321PaymentUriParserTest.kt | 23 ++++++++++++++++++- .../qrscanning/Eip681PaymentUriParserTest.kt | 19 +++++++++++++++ .../qrscanning/SolanaPaymentUriParserTest.kt | 23 ++++++++++++++++++- .../qrscanning/TronPaymentUriParserTest.kt | 23 ++++++++++++++++++- 9 files changed, 137 insertions(+), 7 deletions(-) diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt index b824e19a4e..76bd17a9b1 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/di/QrScanningDataModule.kt @@ -26,9 +26,9 @@ internal object QrScanningDataModule { blockchainDataProvider = blockchainDataProvider, paymentUriParsers = setOf( Eip681PaymentUriParser(blockchainDataProvider), - TronPaymentUriParser(), - SolanaPaymentUriParser(), - Bip321PaymentUriParser(), + TronPaymentUriParser(blockchainDataProvider), + SolanaPaymentUriParser(blockchainDataProvider), + Bip321PaymentUriParser(blockchainDataProvider), ), ), ) diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt index ba281b2a7f..8e9dadca89 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt @@ -6,6 +6,7 @@ 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 { @@ -36,6 +37,15 @@ internal class Bip321PaymentUriParser( ) } + val isAddressValid = matchingCoins.any { + blockchainDataProvider.validateAddress(it.network, parsed.address) + } + if (!isAddressValid) { + return PaymentUriParser.ParseResult.RecognizedError( + ClassifiedQrContent.Error.Unrecognized(qrCode), + ) + } + return PaymentUriParser.ParseResult.Success( ClassifiedQrContent.PaymentUri( address = parsed.address, diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt index 1a0fa0caac..c4c656be84 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt @@ -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] + 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), ) diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt index 5c00d4eab8..e3a85ded0a 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt @@ -6,6 +6,7 @@ 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 { @@ -29,6 +30,15 @@ internal class SolanaPaymentUriParser( ) } + 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, diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt index 61627b9738..bbf95cf345 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt @@ -8,6 +8,7 @@ import java.math.BigDecimal import java.math.MathContext internal class TronPaymentUriParser( + private val blockchainDataProvider: QrContentClassifierParser.BlockchainDataProvider, private val helper: PaymentUriResolveHelper = PaymentUriResolveHelper(), ) : PaymentUriParser { @@ -31,6 +32,15 @@ internal class TronPaymentUriParser( ) } + 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, diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt index 723ea0ef8c..4d3c46bdec 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt @@ -3,15 +3,21 @@ package com.tangem.data.qrscanning import com.google.common.truth.Truth.assertThat import com.tangem.data.qrscanning.parser.Bip321PaymentUriParser import com.tangem.data.qrscanning.parser.PaymentUriParser +import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.qrscanning.models.ClassifiedQrContent +import io.mockk.every +import io.mockk.mockk import org.junit.Test import java.math.BigDecimal internal class Bip321PaymentUriParserTest { - private val parser = Bip321PaymentUriParser() + private val blockchainDataProvider = mockk { + every { validateAddress(any(), any()) } returns true + } + private val parser = Bip321PaymentUriParser(blockchainDataProvider) // region Basic parsing @@ -138,6 +144,21 @@ internal class Bip321PaymentUriParserTest { // 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( diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt index f43f5e513e..e76d3c6910 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt @@ -17,6 +17,7 @@ internal class Eip681PaymentUriParserTest { private val blockchainDataProvider = mockk { every { getChainId(any()) } returns null every { getBlockchainNameByChainId(any()) } returns null + every { validateAddress(any(), any()) } returns true } private val parser = Eip681PaymentUriParser(blockchainDataProvider) @@ -173,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 diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt index 5f2935b741..22d0e53a40 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt @@ -2,16 +2,22 @@ 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 parser = SolanaPaymentUriParser() + private val blockchainDataProvider = mockk { + every { validateAddress(any(), any()) } returns true + } + private val parser = SolanaPaymentUriParser(blockchainDataProvider) // region Scheme matching @@ -112,6 +118,21 @@ internal class SolanaPaymentUriParserTest { // 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( diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt index 63529bdd52..2525689756 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt @@ -2,16 +2,22 @@ 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 parser = TronPaymentUriParser() + private val blockchainDataProvider = mockk { + every { validateAddress(any(), any()) } returns true + } + private val parser = TronPaymentUriParser(blockchainDataProvider) // region Scheme matching @@ -152,6 +158,21 @@ internal class TronPaymentUriParserTest { // 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( From aeb0dbefe2771ea06d7af936a9a07e38fdf7f4cd Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 26 Mar 2026 16:57:12 +0500 Subject: [PATCH 07/19] Updated on 2026-08-14 --- .../tangempay/model/TangemPayWalletSelectorModel.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayWalletSelectorModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayWalletSelectorModel.kt index 27a15aeeeb..5846a5ad8f 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayWalletSelectorModel.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayWalletSelectorModel.kt @@ -37,13 +37,13 @@ internal class TangemPayWalletSelectorModel @Inject constructor( onWalletClick = { params.listener.onWalletSelected(it) }, ) + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + init { fetchUserWalletsUM() } - val uiState: StateFlow - field = MutableStateFlow(getInitialState()) - private fun getInitialState(): WalletSelectorBSContentUM { return WalletSelectorBSContentUM( userWallets = persistentListOf(), From 0c402cf4721b92c86352f0c34a9cd376ef659d40 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 26 Mar 2026 14:59:24 +0000 Subject: [PATCH 08/19] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index f98056e6bf..5f02e77766 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.35-1449" +tangemBlockchainSdk = "releases-5.36-1457" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.35-596" +tangemCardSdk = "releases-5.36-599" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-547" +tangemHotSdk = "develop-549" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From 5e7e57b94235cb4a62b28bb845e8a5345f28af3d Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 27 Mar 2026 08:23:29 +0500 Subject: [PATCH 09/19] Updated on 2026-08-14 --- .../wallet/loaders/implementors/MultiWalletContentLoader.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt index 69217500ac..dc5517147a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt @@ -17,6 +17,7 @@ internal class MultiWalletContentLoader @AssistedInject constructor( private val walletNotificationsSubscriberFactory: WalletNotificationsSubscriber.Factory, private val multiWalletActionButtonsSubscriberFactory: MultiWalletActionButtonsSubscriber.Factory, private val tangemPayMainSubscriberFactory: TangemPayMainSubscriber.Factory, + private val tokenListAnalyticsSubscriberFactory: TokenListAnalyticsSubscriber.Factory, private val designFeatureToggles: DesignFeatureToggles, ) : WalletContentLoader(id = userWallet.walletId) { @@ -31,6 +32,7 @@ internal class MultiWalletContentLoader @AssistedInject constructor( }, multiWalletActionButtonsSubscriberFactory.create(userWallet), tangemPayMainSubscriberFactory.create(userWallet), + tokenListAnalyticsSubscriberFactory.create(userWallet), ) @AssistedFactory From 1ec1196e1227a5f196303fb7e9b2aa3786bec8e8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 27 Mar 2026 06:30:43 +0300 Subject: [PATCH 10/19] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 3 +- core/res/src/main/res/values-es/strings.xml | 1 + core/res/src/main/res/values-fr/strings.xml | 1 + core/res/src/main/res/values-it/strings.xml | 1 + core/res/src/main/res/values-ja/strings.xml | 6 +- .../src/main/res/values-pt-rBR/strings.xml | 22 +++++ core/res/src/main/res/values-ru/strings.xml | 1 + .../src/main/res/values-uk-rUA/strings.xml | 1 + .../src/main/res/values-zh-rCN/strings.xml | 2 +- .../src/main/res/values-zh-rTW/strings.xml | 1 + core/res/src/main/res/values/strings.xml | 18 +++- .../parser/Bip321PaymentUriParser.kt | 13 ++- .../parser/Eip681PaymentUriParser.kt | 52 +++++++++--- .../qrscanning/parser/PaymentUriParser.kt | 6 ++ .../parser/PaymentUriResolveHelper.kt | 32 ++++++- .../parser/QrContentClassifierParser.kt | 6 ++ .../data/qrscanning/parser/QrSentUriParser.kt | 33 ++++++-- .../parser/SolanaPaymentUriParser.kt | 12 ++- .../qrscanning/parser/TronPaymentUriParser.kt | 12 ++- .../DefaultQrScanningEventsRepository.kt | 8 +- .../qrscanning/Bip321PaymentUriParserTest.kt | 83 +++++++++++++++++-- .../qrscanning/Eip681PaymentUriParserTest.kt | 56 ++++++++++++- .../qrscanning/SolanaPaymentUriParserTest.kt | 48 ++++++++++- .../qrscanning/TronPaymentUriParserTest.kt | 48 ++++++++++- .../qrscanning/models/ClassifiedQrContent.kt | 5 ++ .../domain/qrscanning/models/QrSendTarget.kt | 5 ++ .../usecases/ResolveQrSendTargetsUseCase.kt | 7 ++ .../wallet/child/wallet/model/WalletModel.kt | 15 +++- .../wallet/state/model/WalletAlertUM.kt | 18 ++++ 29 files changed, 466 insertions(+), 50 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index c4760bb8a5..5132421c98 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -404,6 +404,7 @@ Aufgrund der Beschränkungen von %1$s können nur %2$d UTXOs in eine einzige Transaktion passen. Das bedeutet, dass du nur %3$s oder weniger senden kannst. Du musst den Betrag reduzieren. Wert kopiert Meine Wallet + Warnung Woche mit Ja @@ -693,7 +694,7 @@ Lege die Karte oder Ring an Deine Wallet ist eingerichtet und einsatzbereit! Wallet erfolgreich importiert - Wiederherstellung %d% + Wiederherstellung %d%% Du hast deine biometrischen Daten aktualisiert, scanne deine Karte oder Ring, um einzutreten Dein Guthaben sollte höher sein als der Gebührenwert, um eine Überweisung zu tätigen Unzureichende Mittel diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 4fde82b922..1b40ed9ebf 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -401,6 +401,7 @@ Debido a limitaciones sobre %1$s, solo %2$d UTXO pueden caber en una sola transacción. Esto significa que solo puedes enviar %3$s o menos. Debe reducir la cantidad. Valor copiado Billeteras + Alerta semana con diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 89c9435c9b..772f4d92d2 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -400,6 +400,7 @@ Unstakez En raison d\'une limitations sur les %1$s, seuls les %2$d UTXO peuvent s\'intégrer dans une seule transaction. Ce qui signifie vous ne pouvez envoyer que %3$s ou moins. Réduisez le montant. Valeur copiée + Alerte semaine avec Oui diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index 9cd39c1c7a..0282ef7661 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -26,6 +26,7 @@ %d gettone %d gettoni + Avviso Codice di accesso Prima di scansionare la carta sarà necessario inserire il codice di accesso corretto Mantenimento della carta diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 6efdf8cd26..6dadf3d9d5 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -301,6 +301,7 @@ 時間 インポート 進行中 + 残高不足 後で もっと詳しく 残り%1$s @@ -397,6 +398,7 @@ %1$sの制限により、1つのトランザクションに収まるUTXOは%2$d個のみです。つまり、 %3$s以下しか送信できません。量を減らす必要があります。 値がコピーされました ウォレット + 警告 はい @@ -473,7 +475,7 @@ 絞り込み マイネットワーク ネットワーク - よく使われています + 人気 該当する結果はありません ステーキング・利息モード こんにちは、サポートチームの皆さん、コード %s のエラーが発生しました。 @@ -687,7 +689,7 @@ カードまたはリングをタップ ウォレットは同期され、準備ができています。\n一部のトークンが見つかりませんか? ウォレットのインポートが完了しました - %d%を復元中 + %d%%を復元中 生体認証を更新しました。カードまたはリングをスキャンして入ってください。 送金するには、残高が手数料額より高くなければなりません。 残高不足 diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 04ca28b1d4..c6642f4bfa 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -306,6 +306,7 @@ hora Importar Em andamento + Saldo insuficiente Mais tarde Saber mais %1$s falta @@ -404,6 +405,7 @@ Devido a %1$s limitações apenas %2$d Os UTXOs podem caber em uma única transação. Isso significa que você só pode enviar %3$s ou menos. Você precisa reduzir a quantidade. Valor copiado Carteiras + Aviso semana com Sim @@ -691,6 +693,9 @@ Toque para digitalizar Toque para assinar Toque no Cartão ou no Anel + Sua carteira está sincronizada e pronta. \n Faltam alguns tokens? + Carteira importada com sucesso. + Restaurando %d%% Você atualizou seus dados biométricos. Escaneie seu cartão ou toque a campainha para entrar. Seu saldo deve ser superior ao valor da taxa para efetuar uma transferência. Saldo insuficiente @@ -751,6 +756,7 @@ Resultado Veja tokens com capitalização de mercado inferior a 100 mil dólares. Mostrar tokens + Criptomoedas, notícias e muito mais Nenhum resultado Selecione a rede Selecione a carteira @@ -823,9 +829,11 @@ Posição no ranking de criptomoedas entre todas as moedas com base na capitalização de mercado. Posicionamento de mercado Fornecimento máximo + Circulação e Fornecimento Máximo O número máximo de moedas ou tokens que podem existir para uma determinada criptomoeda. Fornecimento máximo Métricas + Sem limite Links oficiais Desempenho de preço Repositório @@ -1100,6 +1108,12 @@ Configurações Você não concedeu acesso à sua câmera. Acesso à câmera negado + O token solicitado não foi adicionado à sua carteira. Adicione-o e tente novamente. + Token não adicionado + Desculpe, este código QR não pôde ser reconhecido. + Código QR não reconhecido + Esta rede não é compatível com nenhum dos tokens adicionados. Adicione um token compatível para enviar criptomoedas. + Nenhum token compatível encontrado Não é necessário memorando %1$s (%2$s) sobre %3$s rede %1$s sobre %2$s rede @@ -1245,6 +1259,11 @@ Limitação de transação Opcional Alinhe o seu código QR com o quadrado para escaneá-lo. Certifique-se de escanear corretamente. %s endereço de rede. + Ao utilizar uma taxa fixa, o valor que você recebe fica garantido no momento da troca. Isso protege você de variações de preço durante a transação. + Taxa fixa + Uma taxa flutuante significa que o valor final que você recebe pode variar ligeiramente com base nas condições de mercado entre o momento em que você inicia e conclui a operação de swap. + Taxa flutuante + A taxa é fixa. Recente Destinatário Endereço inválido @@ -1288,6 +1307,7 @@ O destinatário recebe %s Tem certeza de que deseja cancelar a conversão? Seus dados anteriores serão apagados. Remover conversão + Algo deu errado. Tente novamente. Enviar com troca Transação enviada Prepare-se para escanear o Cartão ou Anel que deseja configurar. @@ -1481,6 +1501,7 @@ Trocar essa quantidade de tokens selecionados causará um impacto significativo no preço e reduzirá seu resultado. Alto impacto nos preços Fundos insuficientes + Não há fundos suficientes para concluir esta transação. Reduza o valor a receber ou adicione mais fundos. Conceder permissão Trocar Trocar... @@ -1647,6 +1668,7 @@ Token em %%imagem%% %1$s rede O %1$s (%2$sO token ) é a principal moeda da plataforma. %3$s rede e não pode ser ocultada enquanto você tiver outros tokens dessa rede na lista Não foi possível ocultar %s + N/A Mostrar código QR Troque este token por outro em %1$s taxas de serviço a partir de fevereiro %2$s-%3$s. Trocar com Changelly, %s tarifas diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index a624ed8ccd..2654c8ae24 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -415,6 +415,7 @@ Из-за ограничений %1$s в одну транзакцию может поместиться только %2$d UTXO. Это означает, что вы можете отправить только %3$s или меньше. Вам нужно уменьшить сумму. Значение скопировано Кошельки + Предупреждение неделю с Да diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 14557017ad..67cdf8af99 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -415,6 +415,7 @@ Через обмеження %1$s в одну транзакцію може поміститися тільки %2$d UTXO. Це означає, що ви можете відправити тільки %3$s або менше. Вам потрібно зменшити суму. Скопійовано Гаманці + Увага тиждень з Так diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index be6b9c60c8..9a07214403 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -6,7 +6,7 @@ 解鎖 您的錢包已同步並準備就緒。\n缺少某些代幣? 錢包導入成功 - 恢復%d% + 恢復%d%% diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index c410e833bc..c18d1cec74 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -99,6 +99,7 @@ 我了解 無法觸達 由於 %1$s 的限制,只有%2$d UTXO 可以放入單次交易中。這意味著您只能發送%3$s或更少數量。您需要減少數量。 + 警告 已複製代幣地址 支持的網路 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 5f645487de..fe0d7fd0a8 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -405,6 +405,7 @@ Due to %1$s limitations only %2$d UTXOs can fit in a single transaction. This means you can only send %3$s or less. You need to reduce the amount. Value copied Wallets + Warning week with Yes @@ -481,7 +482,7 @@ Filter by My networks Networks - Mostly used + Popular No results Staking & Yield mode Hi support team, I\'ve encountered an error with code: %s @@ -695,7 +696,7 @@ Tap the card or ring Your wallet is synced and ready.\nSome tokens missing? Wallet successfully imported - Restoring %d% + Restoring %d%% You have updated biometrics, scan your card or ring to enter Your balance should be higher than the fee value to make a transfer Not enough balance @@ -703,7 +704,7 @@ Not enough Mana You can transfer only %s due to the Mana limit imposed by the Koinos network Mana limit - The Koinos network requires Mana for network fees. Your have %1$s/%2$s Mana + The Koinos network requires Mana for network fees. You have %1$s/%2$s Mana Mana level To begin tracking your crypto assets and transactions, add tokens Manage tokens @@ -752,7 +753,10 @@ No data Market Pulse Quick actions + Clear all Search tokens + Recents + In your portfolio Result See tokens under 100k USD market cap Show tokens @@ -829,9 +833,11 @@ Position in crypto rating between all coins based on market capitalization Market position Max supply + Circulating and Max Supply The maximum number of coins or tokens that can ever exist for a particular cryptocurrency Max supply Metrics + No limited Official links Price performance Repository @@ -1112,6 +1118,8 @@ Unrecognized QR Code This network isn\'t supported by any of your added tokens. Add a supported token to send crypto. No supported tokens found + This QR code contains parameters that are not recognized: %s. Some payment details may be lost if you continue. + Unknown Parameters No memo required %1$s (%2$s) on %3$s network %1$s on %2$s network @@ -1497,6 +1505,7 @@ Fee estimation error. Please send feedback to support. You swap Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome. + You may receive significantly less due to low liquidity. Try a smaller amount or another provider. High price impact Insufficient funds Not enough funds to complete this transaction. Reduce the amount to receive or add more funds. @@ -1506,6 +1515,8 @@ You receive Choose token not available + Not enough liquidity for this trade.\nReduce the amount or choose another provider. + Trade too large We would be happy to receive your feedback Tangem Pay is now in beta Card frozen @@ -1666,6 +1677,7 @@ Token in %%image%% %1$s network The %1$s (%2$s) token is the main currency on the %3$s network and cannot be hidden as long as you have other tokens on this network in the list Unable to hide %s + N/A Show QR code Exchange this token for another at %1$s service fees from February %2$s-%3$s. Swap with Changelly, %s fees diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt index 8e9dadca89..66a7d643a6 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Bip321PaymentUriParser.kt @@ -46,17 +46,26 @@ internal class Bip321PaymentUriParser( ) } - return PaymentUriParser.ParseResult.Success( + 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 companion object { + const val PARAM_LABEL = "label" val BLOCKCHAINS = listOf( Blockchain.Bitcoin, Blockchain.BitcoinTestnet, diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt index c4c656be84..69e2c408b8 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/Eip681PaymentUriParser.kt @@ -41,7 +41,7 @@ internal class Eip681PaymentUriParser( } val result = if (parsed.functionName == FUNCTION_TRANSFER) { - val recipient = parsed.params[PARAM_ADDRESS] + val recipient = parsed.params[Param.ADDRESS.key] if (recipient == null) { return PaymentUriParser.ParseResult.RecognizedError( ClassifiedQrContent.Error.Unrecognized(qrCode), @@ -59,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, functionName: String?): Map { + val supportedKeys = if (functionName == FUNCTION_TRANSFER) { + Param.transferParams() + } else { + Param.nativeParams() + } + return params.filterKeys { key -> key !in supportedKeys } } private fun resolveNativeTransfer( @@ -76,7 +90,7 @@ internal class Eip681PaymentUriParser( matchingCoins: List, allCurrencies: List, ): ClassifiedQrContent.PaymentUri? { - val valueWei = parsed.params[PARAM_VALUE]?.toBigDecimalOrNull() + val valueWei = parsed.params[Param.VALUE.key]?.toBigDecimalOrNull() if (matchingCoins.isEmpty()) return null @@ -105,7 +119,7 @@ internal class Eip681PaymentUriParser( matchingCoins: List, allCurrencies: List, ): 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() @@ -118,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( @@ -149,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, @@ -179,15 +193,27 @@ internal class Eip681PaymentUriParser( val params: Map, ) + 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 = setOf(VALUE.key, CHAIN_ID.key) + + /** Params supported for ERC-20 transfer() calls. */ + fun transferParams(): Set = setOf(ADDRESS.key, UINT256.key, CHAIN_ID.key) + } + } + private companion object { // ethereum:
[@][/][?] val URI_REGEX = Regex("""^([^@/?]+)(?:@(\d+))?(?:/([^?]+))?(?:\?(.+))?$""") 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 diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriParser.kt index e7a64b8740..a74168e99b 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriParser.kt @@ -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, + ) : ParseResult() } } \ No newline at end of file diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriResolveHelper.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriResolveHelper.kt index c28f09b726..3df547b4b1 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriResolveHelper.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/PaymentUriResolveHelper.kt @@ -40,7 +40,7 @@ internal class PaymentUriResolveHelper { ClassifiedQrContent.PaymentUri( address = context.parsed.address, amount = amount, - memo = context.parsed.memo, + memo = context.parsed.memo?.second, matchingCurrencies = matchingTokens, ), ) @@ -67,12 +67,40 @@ internal class PaymentUriResolveHelper { ClassifiedQrContent.PaymentUri( address = context.parsed.address, amount = amount, - memo = context.parsed.memo, + memo = context.parsed.memo?.second, matchingCurrencies = matchingCurrencies, ), ) } + fun validateParams( + result: PaymentUriParser.ParseResult, + unconsumedParams: Map, + memo: Pair?, + matchingCoins: List, + ): 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): Boolean { + return matchingCoins.any { + it.network.transactionExtrasType != Network.TransactionExtrasType.NONE + } + } + data class ResolveContext( val parsed: QrSentUriParser.Result, val matchingCoins: List, diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt index 3f8817247d..ca6fed1fc2 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrContentClassifierParser.kt @@ -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 } diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt index af64a3c485..2655f9ee3b 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/QrSentUriParser.kt @@ -8,8 +8,8 @@ internal class QrSentUriParser { data class Result( val address: String, val amount: BigDecimal?, - val memo: String?, - val params: Map, + val memo: Pair?, + val remainingParams: Map, ) 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] ?: params[PARAM_DT])?.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,9 +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_DT = "dt" const val PARAM_ADDRESS = "address" const val PARAM_VALUE = "value" const val PARAM_UINT256 = "uint256" diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt index e3a85ded0a..cd9cee1f8c 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/SolanaPaymentUriParser.kt @@ -49,12 +49,20 @@ internal class SolanaPaymentUriParser( qrCode = qrCode, ) - val splTokenMint = parsed.params[PARAM_SPL_TOKEN] - return if (splTokenMint != null) { + 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 { diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt index bbf95cf345..985e94855d 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/parser/TronPaymentUriParser.kt @@ -51,12 +51,20 @@ internal class TronPaymentUriParser( qrCode = qrCode, ) - val tokenContractAddress = parsed.params[PARAM_TOKEN] - return if (tokenContractAddress != null) { + 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, + ) } /** diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt index 33d6158669..8b71bc156a 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt @@ -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() diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt index 4d3c46bdec..cac610a9e7 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt @@ -215,15 +215,76 @@ internal class Bip321PaymentUriParserTest { } @Test - fun `bitcoin URI with memo param`() { + 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 @@ -231,7 +292,11 @@ 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("BTC", "Bitcoin", "BTC", decimals = 8) @@ -243,6 +308,7 @@ internal class Bip321PaymentUriParserTest { name: String, symbol: String, decimals: Int, + extrasType: Network.TransactionExtrasType = Network.TransactionExtrasType.NONE, ): CryptoCurrency.Coin { return CryptoCurrency.Coin( id = CryptoCurrency.ID( @@ -250,7 +316,7 @@ internal class Bip321PaymentUriParserTest { body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId), ), - network = buildNetwork(rawNetworkId, name, symbol), + network = buildNetwork(rawNetworkId, name, symbol, extrasType), name = name, symbol = symbol, decimals = decimals, @@ -276,7 +342,12 @@ internal class Bip321PaymentUriParserTest { ) } - private fun buildNetwork(rawNetworkId: String, name: String, symbol: 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, @@ -287,7 +358,7 @@ internal class Bip321PaymentUriParserTest { standardType = Network.StandardType.Unspecified("UNSPECIFIED"), hasFiatFeeRate = false, canHandleTokens = false, - transactionExtrasType = Network.TransactionExtrasType.NONE, + transactionExtrasType = extrasType, nameResolvingType = Network.NameResolvingType.NONE, ) } diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt index e76d3c6910..a98cbe0026 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt @@ -283,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) diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt index 22d0e53a40..068663caed 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt @@ -146,10 +146,56 @@ internal class SolanaPaymentUriParserTest { // 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 (this as? PaymentUriParser.ParseResult.Success)?.content + return when (this) { + is PaymentUriParser.ParseResult.Success -> content + is PaymentUriParser.ParseResult.SuccessWithWarning -> content + else -> null + } } private val solanaNetwork = buildNetwork("SOLANA", "Solana", "SOL") diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt index 2525689756..8c07742046 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt @@ -186,10 +186,56 @@ internal class TronPaymentUriParserTest { // 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 (this as? PaymentUriParser.ParseResult.Success)?.content + return when (this) { + is PaymentUriParser.ParseResult.Success -> content + is PaymentUriParser.ParseResult.SuccessWithWarning -> content + else -> null + } } private val tronNetwork = buildNetwork("TRON", "Tron", "TRX") diff --git a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt index 1fefc8a555..b9b7b63ff6 100644 --- a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/ClassifiedQrContent.kt @@ -19,6 +19,11 @@ sealed interface ClassifiedQrContent { val matchingCurrencies: List, ) : ClassifiedQrContent + data class PaymentUriWarning( + val paymentUri: PaymentUri, + val unsupportedParams: Map, + ) : ClassifiedQrContent + sealed interface Error : ClassifiedQrContent { /** QR code not recognized by any parser */ diff --git a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt index dcb02f58d4..986824f436 100644 --- a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt @@ -41,6 +41,11 @@ sealed class QrSendTarget { data object AddressSameAsWallet : QrSendTarget() + data class Warning( + val target: QrSendTarget, + val unsupportedParams: Map, + ) : QrSendTarget() + data class WalletConnect(val uri: String) : QrSendTarget() data class Error(val error: ClassifiedQrContent.Error) : QrSendTarget() diff --git a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt index 9353df0a2a..4156a83e1f 100644 --- a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt +++ b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt @@ -73,6 +73,13 @@ class ResolveQrSendTargetsUseCase( matchingCurrencies = classified.matchingCurrencies, portfolioIndex = portfolioIndex, ) + is ClassifiedQrContent.PaymentUriWarning -> { + val inner = resolve(classified.paymentUri, portfolioIndex) + QrSendTarget.Warning( + target = inner, + unsupportedParams = classified.unsupportedParams, + ) + } } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index a65941d7ed..280c099e2d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -755,7 +755,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 @@ -788,6 +793,14 @@ internal class WalletModel @Inject constructor( 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) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletAlertUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletAlertUM.kt index 9b07b7f428..8aa4bdf4a1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletAlertUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletAlertUM.kt @@ -94,6 +94,24 @@ internal object WalletAlertUM { ) } + fun qrCodeUnsupportedParams(unsupportedParams: Map, 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), From a027d5b683c308006fd92a080e7bf1779406a736 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 27 Mar 2026 13:54:01 +0300 Subject: [PATCH 11/19] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 636d7a8aa0..7f1a418f6f 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 636d7a8aa0e330e9b95e91d85f23ad15ac6d913f +Subproject commit 7f1a418f6f2bd52c61c389790b4ff94db51382e2 From 8e8a548bb129f18681691ac73e70198eefc14554 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 27 Mar 2026 16:15:22 +0300 Subject: [PATCH 12/19] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 7f1a418f6f..0a4afd762a 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 7f1a418f6f2bd52c61c389790b4ff94db51382e2 +Subproject commit 0a4afd762ab09fd859461ca94708c639f9880dad From 0022091fd712c373447a7fa0b1b7c936a99fcedf Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 30 Mar 2026 19:38:07 +0300 Subject: [PATCH 13/19] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 5f02e77766..5c6e986a05 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -7,7 +7,7 @@ tangemBlockchainSdk = "releases-5.36-1457" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.36-599" +tangemCardSdk = "releases-5.36-600" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From af8103e00dea1190106de4c3cdd2554a1659d7e9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Apr 2026 12:41:35 +0400 Subject: [PATCH 14/19] Updated on 2026-08-14 --- .../common/ui/notifications/NotificationsFactory.kt | 12 +++++++++++- .../usecase/GetAccountCurrencyStatusUseCaseTest.kt | 2 +- gradle/tangem_dependencies.toml | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt index 1cedf7d5b3..c2ea0c7fe3 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt @@ -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) }, diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt index fbea44938c..8c0081e165 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetAccountCurrencyStatusUseCaseTest.kt @@ -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 diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 5c6e986a05..08ab7327d8 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.36-1457" +tangemBlockchainSdk = "releases-5.36-1470" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.36-600" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From fdc485fbcace4e003598145131526d711ba1e6ad Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Apr 2026 13:47:35 +0300 Subject: [PATCH 15/19] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 0a4afd762a..009cf6332a 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 0a4afd762ab09fd859461ca94708c639f9880dad +Subproject commit 009cf6332a72cf0893167221abf7010d033906c2 From a7a4d5733f7ed9047abb48b68767112c0de6c27c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Apr 2026 16:41:56 +0400 Subject: [PATCH 16/19] Updated on 2026-08-14 --- .../tangem/feature/swap/model/SwapModel.kt | 101 ++++++++++++------ 1 file changed, 69 insertions(+), 32 deletions(-) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 03c229e5c6..df4d6c93e7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -38,6 +38,7 @@ import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -61,6 +62,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalResult +import com.tangem.domain.promo.ShouldShowStoriesUseCase import com.tangem.domain.promo.models.StoryContentIds import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry @@ -68,8 +70,6 @@ import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayWithdrawUseCase -import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase -import com.tangem.domain.promo.ShouldShowStoriesUseCase import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase import com.tangem.domain.transaction.error.GetFeeError @@ -229,7 +229,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) @@ -538,20 +538,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) { + Timber.i( + "updateFeePaidCryptoCurrencyFor: id = ${fromCryptoCurrency.currency.id}, " + + "isReverseFromTo: $isReverseFromTo", + ) updateFeePaidCryptoCurrencyFor(fromCryptoCurrency) } else { Timber.e("updateFeePaidCryptoCurrencyFor failed: fromCryptoCurrency is null") @@ -594,7 +596,7 @@ internal class SwapModel @Inject constructor( state = state, selectedCurrency = addedToken, selectedAccount = addedAccount, - isReverseFromTo = isOrderReversed, + isReverseFromTo = isOrderReversed.value, ) subscribeToCoinBalanceUpdatesIfNeeded() @@ -624,29 +626,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) { + Timber.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 @@ -670,7 +677,7 @@ internal class SwapModel @Inject constructor( toAccount = toAccount, ) ) { - return + return true } startLoadingQuotes( @@ -682,10 +689,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 uiState = stateBuilder.addTokensToStateV2( uiState = uiState, @@ -1278,7 +1286,7 @@ internal class SwapModel @Inject constructor( searchQueryState.value = searchQuery val tokenDataState = dataState.tokensDataState ?: return@debounce - val group = if (isOrderReversed) { + val group = if (isOrderReversed.value) { tokenDataState.fromGroup } else { tokenDataState.toGroup @@ -1308,7 +1316,7 @@ internal class SwapModel @Inject constructor( ) } - val filteredTokenDataState = if (isOrderReversed) { + val filteredTokenDataState = if (isOrderReversed.value) { tokenDataState.copy( fromGroup = tokenDataState.fromGroup.copy( available = available, @@ -1355,7 +1363,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 @@ -1412,6 +1420,10 @@ internal class SwapModel @Inject constructor( return } modelScope.launch { + Timber.i( + "updateFeePaidCryptoCurrencyFor: id = ${fromToken.currency.id}, " + + "isOrderReversed: ${isOrderReversed.value}", + ) updateFeePaidCryptoCurrencyFor(fromToken) startLoadingQuotes( fromToken = fromToken, @@ -1431,7 +1443,7 @@ internal class SwapModel @Inject constructor( tokens: TokensDataStateExpress, id: String, ): Pair { - val accountCryptoCurrencyStatus = if (isOrderReversed) { + val accountCryptoCurrencyStatus = if (isOrderReversed.value) { tokens.fromGroup } else { tokens.toGroup @@ -1463,7 +1475,23 @@ internal class SwapModel @Inject constructor( feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( userWalletId = userWalletId, cryptoCurrencyStatus = currencyStatus, - ).getOrNull() ?: currencyStatus, + ) + .onLeft { + Timber.e( + "Coin balance: Unable to get fee paid crypto currency status for " + + "${currencyStatus.currency.id}", + ) + } + .onRight { status -> + if (status == null) { + Timber.e( + "Coin balance: Fee paid crypto currency status is null " + + "for ${currencyStatus.currency.id}", + ) + } + } + .getOrNull() + ?: currencyStatus, ) } @@ -1509,7 +1537,11 @@ internal class SwapModel @Inject constructor( toCryptoCurrency = newToToken, toAccount = newToAccount, ) - isOrderReversed = !isOrderReversed + isOrderReversed.value = !isOrderReversed.value + Timber.i( + "updateFeePaidCryptoCurrencyFor: id = ${newFromToken.currency.id}, " + + "isOrderReversed: ${isOrderReversed.value}", + ) updateFeePaidCryptoCurrencyFor(newFromToken) dataState.tokensDataState?.let { tokensDataState -> updateTokensState(tokensDataState) @@ -1946,13 +1978,13 @@ internal class SwapModel @Inject constructor( } private fun findSwapProviders(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): List { - 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 @@ -1978,8 +2010,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( @@ -2039,13 +2071,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 -> @@ -2310,7 +2342,10 @@ internal class SwapModel @Inject constructor( val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content if (feeStateUM == null) { - Timber.e("getSelectedFeeState: FeeSelectorUM is not Content: $feeStateUM, returning Legacy state") + Timber.e( + "getSelectedFeeState: FeeSelectorUM is not Content: ${feeSelectorRepository.state.value}, " + + "returning Legacy state", + ) return TxFeeSealedState.Legacy( txFeeState = TxFeeState.Empty, selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL, @@ -2332,7 +2367,9 @@ internal class SwapModel @Inject constructor( val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content if (feeStateUM == null) { - Timber.e("getSelectedFee: FeeSelectorUM is not Content: $feeStateUM, returning null") + Timber.e( + "getSelectedFee: FeeSelectorUM is not Content: ${feeSelectorRepository.state.value}, returning null", + ) return null } From 57f0e83cdcb4e644403481d9b295bc82ba8fbfc5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 31 Mar 2026 18:41:17 +0500 Subject: [PATCH 17/19] Updated on 2026-08-14 --- .../tangem/feature/swap/model/SwapModel.kt | 20 +++++++++++++++---- .../tangem/feature/swap/ui/StateBuilder.kt | 6 ++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index df4d6c93e7..2d207ea99c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -89,10 +89,7 @@ import com.tangem.feature.swap.domain.models.ExpressException 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.AddToPortfolioRoute -import com.tangem.feature.swap.models.SwapAlertUM -import com.tangem.feature.swap.models.SwapStateHolder -import com.tangem.feature.swap.models.UiActions +import com.tangem.feature.swap.models.* import com.tangem.feature.swap.models.market.SwapMarketsListBatchFlowManager import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.models.states.SwapNotificationUM @@ -1424,6 +1421,13 @@ internal class SwapModel @Inject constructor( "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, @@ -2021,6 +2025,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, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 6d5e80eb4a..7dbe528a91 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -184,10 +184,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 = "0 ${appCurrencyProvider.invoke().symbol}", token = fromToken, tokenIconUrl = fromToken.currency.iconUrl, From 567cb766adb6c92da286ebfc740fe4de0799a9ea Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Apr 2026 18:17:10 +0400 Subject: [PATCH 18/19] Updated on 2026-08-14 --- .../java/com/tangem/tap/features/main/MainViewModel.kt | 7 +------ .../java/com/tangem/domain/promo/GetStoryContentUseCase.kt | 5 ++++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index b8fd319359..ae52de34e5 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -48,10 +48,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout import timber.log.Timber import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds @Suppress("LongParameterList", "LargeClass") @HiltViewModel @@ -138,10 +136,7 @@ internal class MainViewModel @Inject constructor( viewModelScope.launch { launchAPIRequests { launch { blockchainSDKFactory.init() } - - launch { - withTimeout(timeMillis = 1.seconds.inWholeMilliseconds) { fetchUserCountry() } - } + launch { fetchUserCountry() } } subscribeToSelectedWallet() diff --git a/domain/promo/src/main/java/com/tangem/domain/promo/GetStoryContentUseCase.kt b/domain/promo/src/main/java/com/tangem/domain/promo/GetStoryContentUseCase.kt index 03a93bc23a..c1f4686134 100644 --- a/domain/promo/src/main/java/com/tangem/domain/promo/GetStoryContentUseCase.kt +++ b/domain/promo/src/main/java/com/tangem/domain/promo/GetStoryContentUseCase.kt @@ -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 { 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) } From 85f0e5858d242b15f30d88a8722d19c93f0b2af7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Apr 2026 14:17:36 +0000 Subject: [PATCH 19/19] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 08ab7327d8..ebcd3e5e91 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,11 +5,11 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.36-1470" +tangemBlockchainSdk = "develop-1461" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.36-600" +tangemCardSdk = "develop-598" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ -tangemVico = "2.0.0-alpha.25-tangem12" +tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ tangemHotSdk = "develop-549" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^