Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-18 13:48:57 +03:00
parent c061cc88f1
commit ed2c3c3078
9 changed files with 153 additions and 29 deletions

View file

@ -14,18 +14,22 @@ internal class Eip681PaymentUriParser(
coins: List<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): PaymentUriParser.ParseResult {
if (!qrCode.startsWith(SCHEME)) return PaymentUriParser.ParseResult.NotRecognized
if (!qrCode.startsWith(SCHEME)) {
return PaymentUriParser.ParseResult.NotRecognized
}
val withoutScheme = qrCode.removePrefix(SCHEME)
val parsed = parseEip681(withoutScheme) ?: return PaymentUriParser.ParseResult.NotRecognized
val matchingCoins = findMatchingCoins(parsed.chainId, coins)
if (matchingCoins.isEmpty()) return PaymentUriParser.ParseResult.RecognizedButNoMatch
if (matchingCoins.isEmpty()) {
return PaymentUriParser.ParseResult.RecognizedButNoMatch
}
val result = if (parsed.functionName == FUNCTION_TRANSFER) {
resolveErc20Transfer(parsed, matchingCoins, allCurrencies)
} else {
resolveNativeTransfer(parsed, matchingCoins)
resolveNativeTransfer(parsed, matchingCoins, allCurrencies)
}
return if (result != null) {
PaymentUriParser.ParseResult.Success(result)
@ -37,6 +41,7 @@ internal class Eip681PaymentUriParser(
private fun resolveNativeTransfer(
parsed: Eip681Result,
matchingCoins: List<CryptoCurrency.Coin>,
allCurrencies: List<CryptoCurrency>,
): ClassifiedQrContent.PaymentUri? {
val valueWei = parsed.params[PARAM_VALUE]?.toBigDecimalOrNull()
@ -45,11 +50,20 @@ internal class Eip681PaymentUriParser(
val decimals = matchingCoins.first().decimals
val amount = valueWei?.fromSmallestUnit(decimals)
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
// If value is specified, this is a native coin transfer — return only coins
// If no value, it's just an address with scheme — return all currencies on the network
val matchingCurrencies = if (valueWei != null) {
matchingCoins
} else {
allCurrencies.filter { it.network.id in matchingNetworkIds }
}
return ClassifiedQrContent.PaymentUri(
address = parsed.targetAddress,
amount = amount,
memo = null,
matchingCurrencies = matchingCoins,
matchingCurrencies = matchingCurrencies,
)
}
@ -63,22 +77,22 @@ internal class Eip681PaymentUriParser(
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
val tokens = allCurrencies.filterIsInstance<CryptoCurrency.Token>()
val token = tokens
.firstOrNull { token ->
val matchingTokens = allCurrencies.filterIsInstance<CryptoCurrency.Token>()
.filter { token ->
token.network.id in matchingNetworkIds &&
token.contractAddress.equals(contractAddress, ignoreCase = true)
} ?: return null
}
if (matchingTokens.isEmpty()) return null
val rawAmount = parsed.params[PARAM_UINT256]?.toBigDecimalOrNull()
val amount = rawAmount?.fromSmallestUnit(token.decimals)
val amount = rawAmount?.fromSmallestUnit(matchingTokens.first().decimals)
return ClassifiedQrContent.PaymentUri(
address = recipient,
amount = amount,
memo = null,
matchingCurrencies = listOf(token),
matchingCurrencies = matchingTokens,
)
}

View file

@ -29,10 +29,9 @@ internal class QrContentClassifierParser(
is PaymentUriParser.ParseResult.NotRecognized -> Unit
}
val matchingNetworkIds = uniqueCoins
val matchingCoins = uniqueCoins
.filter { coin -> blockchainDataProvider.validateAddress(coin.network, qrCode) }
.map { it.network.id }
.toSet()
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
if (matchingNetworkIds.isNotEmpty()) {
val matchingCurrencies = userCurrencies.filter { it.network.id in matchingNetworkIds }

View file

@ -70,7 +70,7 @@ internal class Eip681PaymentUriParserTest {
}
@Test
fun `native transfer includes only coins, not tokens`() {
fun `native transfer with value returns only coins`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
@ -85,6 +85,22 @@ internal class Eip681PaymentUriParserTest {
assertThat(result!!.matchingCurrencies).containsExactly(ethereumCoin)
}
@Test
fun `native transfer without value returns all currencies on matching network`() {
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
val result = parser.parse(
qrCode = "ethereum:0xRecipient@1",
coins = listOf(ethereumCoin),
allCurrencies = listOf(ethereumCoin, usdcToken),
).asSuccess()
assertThat(result).isNotNull()
assertThat(result!!.matchingCurrencies).containsExactly(ethereumCoin, usdcToken)
}
// endregion
// region ERC-20 transfer

View file

@ -27,19 +27,19 @@ class ResolveQrSendTargetsUseCase(
val currencyLocations = mutableMapOf<CryptoCurrency.ID, MutableList<CurrencyLocation>>()
val totalPerAccount = mutableMapOf<AccountId, Int>()
for (accountList in allAccountLists) {
for (account in accountList.accounts.filterIsInstance<Account.CryptoPortfolio>()) {
val location = CurrencyLocation(
walletName = walletNamesMap[account.accountId.userWalletId]
?: account.accountId.userWalletId.stringValue,
accountId = account.accountId,
accountName = account.accountName,
)
totalPerAccount[account.accountId] = account.cryptoCurrencies.size
for (currency in account.cryptoCurrencies) {
allCurrencies.add(currency)
currencyLocations.getOrPut(currency.id) { mutableListOf() }.add(location)
}
val allAccounts = allAccountLists.flatMap { it.accounts }.filterIsInstance<Account.CryptoPortfolio>()
for (account in allAccounts) {
val location = CurrencyLocation(
walletName = walletNamesMap[account.accountId.userWalletId]
?: account.accountId.userWalletId.stringValue,
accountId = account.accountId,
accountName = account.accountName,
)
totalPerAccount[account.accountId] = account.cryptoCurrencies.size
for (currency in account.cryptoCurrencies) {
allCurrencies.add(currency)
currencyLocations.getOrPut(currency.id) { mutableListOf() }.add(location)
}
}

View file

@ -30,10 +30,12 @@ import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen
import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen2
import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedComponent
import com.tangem.feature.walletsettings.component.RenameWalletComponent
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.pushnotifications.api.PushNotificationsBottomSheetComponent
import com.tangem.features.pushnotifications.api.PushNotificationsParams
import com.tangem.features.send.v2.api.NetworkSelectionComponent
import com.tangem.features.tokenreceive.TokenReceiveComponent
import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent
import dagger.assisted.Assisted
@ -52,6 +54,7 @@ internal class WalletComponent @AssistedInject constructor(
private val pushNotificationsBottomSheetComponent: PushNotificationsBottomSheetComponent.Factory,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
private val yieldSupplyDepositedWarningComponent: YieldSupplyDepositedWarningComponent.Factory,
private val networkSelectionComponentFactory: NetworkSelectionComponent.Factory,
private val designFeatureToggles: DesignFeatureToggles,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
@ -156,6 +159,41 @@ internal class WalletComponent @AssistedInject constructor(
),
)
}
is WalletDialogConfig.NetworkSelection -> {
networkSelectionComponentFactory.create(
context = childByContext(componentContext),
params = NetworkSelectionComponent.Params(
address = dialogConfig.address,
amount = dialogConfig.amount,
memo = dialogConfig.memo,
walletGroups = dialogConfig.walletGroups.map { walletGroup ->
NetworkSelectionComponent.Params.WalletGroup(
userWalletId = walletGroup.userWalletId,
walletName = walletGroup.walletName,
accounts = walletGroup.accounts.map { accountGroup ->
NetworkSelectionComponent.Params.AccountGroup(
accountId = accountGroup.accountId,
accountName = accountGroup.accountName,
currencies = accountGroup.currencies,
hiddenTokensCount = accountGroup.hiddenTokensCount,
)
},
)
},
onTokenSelected = { userWalletId, currency ->
model.innerWalletRouter.dialogNavigation.dismiss()
model.innerWalletRouter.openSend(
userWalletId = userWalletId,
currency = currency,
address = dialogConfig.address,
amount = dialogConfig.amount?.parseBigDecimal(currency.decimals),
tag = dialogConfig.memo,
)
},
onDismiss = model.innerWalletRouter.dialogNavigation::dismiss,
),
)
}
}
},
)

View file

@ -776,7 +776,7 @@ internal class WalletModel @Inject constructor(
)
}
is QrSendTarget.Multiple -> {
// TODO: [REDACTED_TASK_KEY] Bottom sheet: Wallets (dropdown) → Accounts → Tokens
innerWalletRouter.openNetworkSelectionBottomSheet(target)
}
is QrSendTarget.Unknown -> {
// TODO: [REDACTED_TASK_KEY] Error handling for unsupported and invalid QR codes

View file

@ -13,6 +13,7 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.qrscanning.models.QrSendTarget
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
@ -197,6 +198,30 @@ internal class DefaultWalletRouter @Inject constructor(
)
}
override fun openNetworkSelectionBottomSheet(target: QrSendTarget.Multiple) {
dialogNavigation.activate(
configuration = WalletDialogConfig.NetworkSelection(
address = target.address,
amount = target.amount,
memo = target.memo,
walletGroups = target.walletGroups.map { walletGroup ->
WalletDialogConfig.NetworkSelection.WalletGroupData(
userWalletId = walletGroup.userWalletId,
walletName = walletGroup.walletName,
accounts = walletGroup.accounts.map { accountGroup ->
WalletDialogConfig.NetworkSelection.AccountGroupData(
accountId = accountGroup.accountId,
accountName = accountGroup.accountName,
currencies = accountGroup.currencies,
hiddenTokensCount = accountGroup.hiddenTokensCount,
)
},
)
},
),
)
}
inner class OrganizeCallbacks : OrganizeTokensComponent.Callback {
override fun onDismiss() {
dialogNavigation.dismiss()

View file

@ -8,6 +8,7 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.qrscanning.models.QrSendTarget
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
@ -95,4 +96,7 @@ internal interface InnerWalletRouter {
/** Open send screen with prefilled destination */
fun openSend(userWalletId: UserWalletId, currency: CryptoCurrency, address: String, amount: String?, tag: String?)
/** Open network selection bottom sheet for multiple QR matches */
fun openNetworkSelectionBottomSheet(target: QrSendTarget.Multiple)
}

View file

@ -1,11 +1,15 @@
package com.tangem.feature.wallet.presentation.wallet.state.model
import com.tangem.domain.models.TokenReceiveConfig
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.serialization.BigDecimalSerializer
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.details.TokenAction
import kotlinx.collections.immutable.ImmutableList
import kotlinx.serialization.Serializable
import java.math.BigDecimal
/**
* Wallet dialog config. Used to show Decompose dialogs
@ -44,4 +48,28 @@ internal sealed interface WalletDialogConfig {
@Serializable
data class OrganizeTokens(val userWalletId: UserWalletId) : WalletDialogConfig
@Serializable
data class NetworkSelection(
val address: String,
val amount: @Serializable(BigDecimalSerializer::class) BigDecimal?,
val memo: String?,
val walletGroups: List<WalletGroupData>,
) : WalletDialogConfig {
@Serializable
data class WalletGroupData(
val userWalletId: UserWalletId,
val walletName: String,
val accounts: List<AccountGroupData>,
)
@Serializable
data class AccountGroupData(
val accountId: AccountId,
val accountName: AccountName,
val currencies: List<CryptoCurrency>,
val hiddenTokensCount: Int = 0,
)
}
}