Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-24 10:04:41 +07:00
parent 8af3e349a7
commit 3cdfcb6ebc
13 changed files with 149 additions and 37 deletions

View file

@ -12,6 +12,8 @@ android {
dependencies {
/* Project - Domain */
implementation(projects.domain.account)
implementation(projects.domain.account.status)
implementation(projects.domain.walletConnect)
implementation(projects.domain.walletConnect.models)
implementation(projects.domain.transaction)

View file

@ -21,6 +21,9 @@ import com.tangem.data.walletconnect.utils.WcScope
import com.tangem.datasource.di.SdkMoshi
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.walletconnect.WcPairService
import com.tangem.domain.walletconnect.WcRequestService
@ -92,6 +95,7 @@ internal object WalletConnectDataModule {
getWallets: GetWalletsUseCase,
wcNetworksConverter: WcNetworksConverter,
analytics: AnalyticsEventHandler,
accountsFeatureToggles: AccountsFeatureToggles,
wcScope: WcScope,
): DefaultWcSessionsManager {
return DefaultWcSessionsManager(
@ -100,6 +104,7 @@ internal object WalletConnectDataModule {
getWallets = getWallets,
wcNetworksConverter = wcNetworksConverter,
analytics = analytics,
accountsFeatureToggles = accountsFeatureToggles,
scope = wcScope,
)
}
@ -179,22 +184,24 @@ internal object WalletConnectDataModule {
namespaceConverters: Set<@JvmSuppressWildcards WcNamespaceConverter>,
walletManagersFacade: WalletManagersFacade,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
): WcNetworksConverter = WcNetworksConverter(
namespaceConverters = namespaceConverters,
walletManagersFacade = walletManagersFacade,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
)
@Provides
@Singleton
fun associateNetworksDelegate(
namespaceConverters: Set<@JvmSuppressWildcards WcNamespaceConverter>,
networksConverter: WcNetworksConverter,
multiAccountListSupplier: MultiAccountListSupplier,
getWallets: GetWalletsUseCase,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
): AssociateNetworksDelegate = AssociateNetworksDelegate(
namespaceConverters = namespaceConverters,
getWallets = getWallets,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
networksConverter = networksConverter,
multiAccountListSupplier = multiAccountListSupplier,
)
@Provides

View file

@ -2,22 +2,25 @@ package com.tangem.data.walletconnect.pair
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.Wallet.Model.Namespace
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.data.walletconnect.utils.WcNetworksConverter
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
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.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.walletconnect.model.WcPairError
import com.tangem.domain.walletconnect.model.WcSessionProposal.ProposalNetwork
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
internal class AssociateNetworksDelegate(
private val namespaceConverters: Set<WcNamespaceConverter>,
private val networksConverter: WcNetworksConverter,
private val multiAccountListSupplier: MultiAccountListSupplier,
private val getWallets: GetWalletsUseCase,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
) {
@Throws(WcPairError.UnsupportedBlockchains::class)
@ -29,18 +32,48 @@ internal class AssociateNetworksDelegate(
.subtract(requiredNamespaces)
return userWallets.associateWith { wallet ->
mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces, sessionProposal)
mapNetworksForPortfolio(wallet, null, requiredNamespaces, optionalNamespaces, sessionProposal)
}
}
@Throws(WcPairError.UnsupportedBlockchains::class)
suspend fun associateAccounts(sessionProposal: Wallet.Model.SessionProposal): Map<AccountId, ProposalNetwork> {
val userWallets = getWallets.invokeSync()
.filter { it.isMultiCurrency && !it.isLocked }
val requiredNamespaces: Set<String> = sessionProposal.requiredNamespaces.setOfChainId()
val optionalNamespaces: Set<String> = sessionProposal.optionalNamespaces.setOfChainId()
// remove duplicates
.subtract(requiredNamespaces)
val allAccounts = multiAccountListSupplier.invoke()
.filter { it.isNotEmpty() }
.first()
.filter { accountList -> userWallets.any { accountList.userWalletId == it.walletId } }
.map { accountList -> accountList to userWallets.first { it.walletId == accountList.userWalletId } }
return allAccounts
.map { (accountList, wallet) -> accountList.accounts.map { account -> account to wallet } }
.flatten()
.associate { (account, wallet) ->
account.accountId to mapNetworksForPortfolio(
wallet = wallet,
account = account,
requiredNamespaces = requiredNamespaces,
optionalNamespaces = optionalNamespaces,
sessionProposal = sessionProposal,
)
}
}
@Suppress("ComplexCondition")
private suspend fun mapNetworksForWallet(
private suspend fun mapNetworksForPortfolio(
wallet: UserWallet,
account: Account?,
requiredNamespaces: Set<String>,
optionalNamespaces: Set<String>,
sessionProposal: Wallet.Model.SessionProposal,
): ProposalNetwork {
val walletNetworks = getWalletNetworks(userWalletId = wallet.walletId)
val portfolioNetworks = account?.let { getAccountNetworks(it.accountId) }
?: getWalletNetworks(userWalletId = wallet.walletId)
val unknownRequired = mutableSetOf<String>()
val unknownOptional = mutableSetOf<String>()
@ -50,28 +83,28 @@ internal class AssociateNetworksDelegate(
val notAdded = mutableSetOf<Network>()
requiredNamespaces.forEach { chainId ->
val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) }
val wcNetwork = networksConverter.createNetwork(chainId, wallet)
if (wcNetwork == null) {
unknownRequired.add(missingNetworkName(chainId))
return@forEach
}
val walletNetwork = walletNetworks.find { network -> wcNetwork.rawId == network.rawId }
val portfolioNetwork = portfolioNetworks.find { network -> wcNetwork.rawId == network.rawId }
if (walletNetwork == null) {
if (portfolioNetwork == null) {
missingRequired.add(wcNetwork)
} else {
required.add(walletNetwork)
required.add(portfolioNetwork)
}
}
optionalNamespaces.forEach { chainId ->
val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) }
val wcNetwork = networksConverter.createNetwork(chainId, wallet)
if (wcNetwork == null) {
unknownOptional.add(missingNetworkName(chainId))
return@forEach
}
val walletNetwork = walletNetworks.find { network -> wcNetwork.rawId == network.rawId }
if (walletNetwork != null) {
available.add(walletNetwork)
val portfolioNetwork = portfolioNetworks.find { network -> wcNetwork.rawId == network.rawId }
if (portfolioNetwork != null) {
available.add(portfolioNetwork)
} else {
notAdded.add(wcNetwork)
}
@ -88,16 +121,18 @@ internal class AssociateNetworksDelegate(
required = required,
available = available,
notAdded = notAdded,
account = account,
)
}
private suspend fun getWalletNetworks(userWalletId: UserWalletId): List<Network> {
return multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId),
)
.orEmpty()
.filterIsInstance<CryptoCurrency.Coin>()
.map(CryptoCurrency.Coin::network)
return networksConverter.getWalletNetworks(userWalletId)
// flatten all derivation
.distinctBy { it.rawId }
}
private suspend fun getAccountNetworks(accountId: AccountId): List<Network> {
return networksConverter.getAccountNetworks(accountId)
// flatten all derivation
.distinctBy { it.rawId }
}

View file

@ -10,6 +10,7 @@ import com.reown.walletkit.client.Wallet
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.data.walletconnect.utils.getDappOriginUrl
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.blockaid.BlockAidVerifier
import com.tangem.domain.walletconnect.WcAnalyticEvents
import com.tangem.domain.walletconnect.model.*
@ -34,6 +35,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
private val sdkDelegate: WcPairSdkDelegate,
private val blockAidVerifier: BlockAidVerifier,
private val analytics: AnalyticsEventHandler,
private val accountsFeatureToggles: AccountsFeatureToggles,
@Assisted private val pairRequest: WcPairRequest,
) : WcPairUseCase {
@ -111,6 +113,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
val sessionDTO = WcSessionDTO(
topic = "",
walletId = sessionForApprove.wallet.walletId,
accountId = sessionForApprove.account?.accountId,
url = sdkVerifyContext.getDappOriginUrl(),
securityStatus = proposalState.dAppSession.securityStatus,
connectingTime = connectingTime,
@ -196,6 +199,11 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
verifyContext: Wallet.Model.VerifyContext,
): Either<WcPairError, WcPairState.Proposal> = runCatching {
val proposalNetwork = associateNetworksDelegate.associate(sessionProposal)
val proposalAccountNetwork = if (accountsFeatureToggles.isFeatureEnabled) {
associateNetworksDelegate.associateAccounts(sessionProposal)
} else {
null
}
val verificationInfo = when {
verifyContext.validation == Wallet.Model.Validation.INVALID -> CheckDAppResult.UNSAFE
verifyContext.isScam == true -> CheckDAppResult.UNSAFE
@ -204,7 +212,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
CheckDAppResult.FAILED_TO_VERIFY
}
}
val requestedNetworks = proposalNetwork
val requestedNetworks = (proposalAccountNetwork ?: proposalNetwork)
.values.map { it.available.plus(it.required) }.flatten().toSet()
analytics.send(
WcAnalyticEvents.PairRequested(
@ -225,6 +233,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
dAppMetaData = appMetaData,
proposalNetwork = proposalNetwork,
securityStatus = verificationInfo,
proposalAccountNetwork = proposalAccountNetwork,
)
WcPairState.Proposal(dAppSession)
}.fold(

View file

@ -8,6 +8,7 @@ import com.reown.walletkit.client.WalletKit
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.utils.*
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.walletconnect.WcAnalyticEvents
import com.tangem.domain.walletconnect.model.WcSession
@ -31,6 +32,7 @@ internal class DefaultWcSessionsManager(
private val dispatchers: CoroutineDispatcherProvider,
private val wcNetworksConverter: WcNetworksConverter,
private val analytics: AnalyticsEventHandler,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val scope: WcScope,
) : WcSessionsManager, WcSdkObserver {
@ -90,7 +92,11 @@ internal class DefaultWcSessionsManager(
val wcSessions = savedPending.plus(inStore).mapNotNull { storeSession ->
val wallet = wallets.find { it.walletId == storeSession.walletId } ?: return@mapNotNull null
val sdkSession = inSdk.find { it.topic == storeSession.topic } ?: return@mapNotNull null
val networks = wcNetworksConverter.findWalletNetworks(wallet, sdkSession)
val account = storeSession.accountId?.let { wcNetworksConverter.getAccount(it) }
if (accountsFeatureToggles.isFeatureEnabled && account == null) {
return@mapNotNull null
}
val networks = wcNetworksConverter.findWalletNetworks(wallet, account, sdkSession)
val originUrl = storeSession.url ?: sdkSession.metaData?.url ?: ""
WcSession(
wallet = wallet,
@ -102,6 +108,7 @@ internal class DefaultWcSessionsManager(
),
securityStatus = storeSession.securityStatus,
networks = networks,
account = account,
connectingTime = storeSession.connectingTime,
showWalletInfo = wallets.size > 1,
)

View file

@ -3,6 +3,11 @@ package com.tangem.data.walletconnect.utils
import com.reown.walletkit.client.Wallet
import com.tangem.data.common.currency.isCustomCoin
import com.tangem.data.walletconnect.model.CAIP10
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -18,6 +23,7 @@ import javax.inject.Inject
internal class WcNetworksConverter @Inject constructor(
private val namespaceConverters: Set<WcNamespaceConverter>,
private val walletManagersFacade: WalletManagersFacade,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
) {
@ -67,8 +73,13 @@ internal class WcNetworksConverter @Inject constructor(
return allCoinNetwork
}
suspend fun findWalletNetworks(wallet: UserWallet, sdkSession: Wallet.Model.Session): Set<Network> {
val walletNetworks = getWalletNetworks(wallet.walletId)
suspend fun findWalletNetworks(
wallet: UserWallet,
account: Account?,
sdkSession: Wallet.Model.Session,
): Set<Network> {
val portfolioNetworks = account?.let { getAccountNetworks(it.accountId) }
?: getWalletNetworks(wallet.walletId)
val existNetworks = sdkSession.namespaces.values
.map { it.accounts }.flatten().toSet()
.mapNotNull { CAIP10.fromRaw(it) }
@ -76,7 +87,7 @@ internal class WcNetworksConverter @Inject constructor(
val blockchain = namespaceConverters
.firstNotNullOfOrNull { it.toBlockchain(caip10.chainId) }
?: return@mapNotNullTo null
walletNetworks
portfolioNetworks
// find all derivation
.filter { it.rawId == blockchain.id }
// find equal address
@ -89,18 +100,38 @@ internal class WcNetworksConverter @Inject constructor(
return existNetworks
}
suspend fun getAccount(accountId: AccountId): Account? {
return getAccountStatus(accountId)?.account
}
suspend fun convertNetworksForApprove(sessionForApprove: WcSessionApprove): List<Network> {
val walletNetworks = getWalletNetworks(sessionForApprove.wallet.walletId)
val portfolioNetworks = sessionForApprove.account?.let { getAccountNetworks(it.accountId) }
?: getWalletNetworks(sessionForApprove.wallet.walletId)
return sessionForApprove.network
.map { network -> walletNetworks.filter { walletNetwork -> walletNetwork.rawId == network.rawId } }
.map { network -> portfolioNetworks.filter { walletNetwork -> walletNetwork.rawId == network.rawId } }
.flatten()
}
private suspend fun getWalletNetworks(userWalletId: UserWalletId): List<Network> {
suspend fun getWalletNetworks(userWalletId: UserWalletId): List<Network> {
return multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId),
)
.orEmpty()
.filterIsInstance<CryptoCurrency.Coin>().map(CryptoCurrency.Coin::network)
}
private suspend fun getAccountStatus(accountId: AccountId): AccountStatus? {
return singleAccountStatusListSupplier.getSyncOrNull(
SingleAccountStatusListProducer.Params(accountId.userWalletId),
)?.accountStatuses?.find { it.account.accountId == accountId }
}
suspend fun getAccountNetworks(accountId: AccountId): List<Network> {
return getAccountStatus(accountId)
?.flattenCurrencies()
?.map { it.currency }
?.filterIsInstance<CryptoCurrency.Coin>()
?.map(CryptoCurrency.Coin::network)
?: emptyList()
}
}

View file

@ -14,9 +14,13 @@ import com.tangem.data.walletconnect.pair.CaipNamespaceDelegate
import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase
import com.tangem.data.walletconnect.pair.WcPairSdkDelegate
import com.tangem.data.walletconnect.utils.WcSdkSessionConverter
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.blockaid.BlockAidVerifier
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletconnect.model.*
import com.tangem.domain.walletconnect.model.WcPairError
import com.tangem.domain.walletconnect.model.WcPairRequest
import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.model.WcSessionApprove
import com.tangem.domain.walletconnect.usecase.pair.WcPairState
import io.mockk.coEvery
import io.mockk.coVerifyOrder
@ -33,6 +37,7 @@ internal class DefaultWcPairUseCaseTest {
private val analytics: AnalyticsEventHandler = mockk<AnalyticsEventHandler>(relaxed = true)
private val sdkDelegate: WcPairSdkDelegate = mockk<WcPairSdkDelegate>()
private val blockAidVerifier: BlockAidVerifier = mockk<BlockAidVerifier>()
private val accountsFeatureToggles = mockk<AccountsFeatureToggles>()
private val url = "testUrl"
private val source = WcPairRequest.Source.QR
@ -70,6 +75,7 @@ internal class DefaultWcPairUseCaseTest {
get() = WcSessionApprove(
wallet = MockUserWalletFactory.create(),
network = listOf(),
account = null,
)
private val sdkApprove: Wallet.Params.SessionApprove
@ -102,6 +108,7 @@ internal class DefaultWcPairUseCaseTest {
networks = setOf(),
connectingTime = null,
showWalletInfo = false,
account = null,
)
private fun useCaseFactory() = DefaultWcPairUseCase(
@ -110,12 +117,14 @@ internal class DefaultWcPairUseCaseTest {
sdkDelegate = sdkDelegate,
blockAidVerifier = blockAidVerifier,
analytics = analytics,
accountsFeatureToggles = accountsFeatureToggles,
pairRequest = WcPairRequest(userWalletId = UserWalletId(""), uri = url, source = source),
)
@Before
fun setup() {
coEvery { associateNetworksDelegate.associate(sdkProposal) } returns mapOf()
coEvery { accountsFeatureToggles.isFeatureEnabled } returns false
coEvery {
caipNamespaceDelegate.associate(
sessionProposal = sdkProposal,

View file

@ -58,6 +58,7 @@ internal class WcSignUseCaseDelegateTest {
session = WcSession(
wallet = MockUserWalletFactory.create(),
networks = setOf(),
account = null,
securityStatus = CheckDAppResult.FAILED_TO_VERIFY,
connectingTime = 0L,
sdkModel = WcSdkSession(

View file

@ -1,12 +1,14 @@
package com.tangem.domain.walletconnect.model
import com.domain.blockaid.models.dapp.CheckDAppResult
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.network.Network
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSession
import com.tangem.domain.models.wallet.UserWallet
data class WcSession(
val wallet: UserWallet,
val account: Account?,
val networks: Set<Network>,
val sdkModel: WcSdkSession,
val securityStatus: CheckDAppResult,

View file

@ -1,9 +1,11 @@
package com.tangem.domain.walletconnect.model
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
data class WcSessionApprove(
val wallet: UserWallet,
val account: Account?,
val network: List<Network>,
)

View file

@ -2,12 +2,14 @@ package com.tangem.domain.walletconnect.model
import com.domain.blockaid.models.dapp.CheckDAppResult
import com.squareup.moshi.JsonClass
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
@JsonClass(generateAdapter = true)
data class WcSessionDTO(
val topic: String,
val walletId: UserWalletId,
val accountId: AccountId? = null,
val url: String?,
val securityStatus: CheckDAppResult = CheckDAppResult.FAILED_TO_VERIFY,
val connectingTime: Long? = null,

View file

@ -1,18 +1,22 @@
package com.tangem.domain.walletconnect.model
import com.domain.blockaid.models.dapp.CheckDAppResult
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.network.Network
import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData
data class WcSessionProposal(
val dAppMetaData: WcAppMetaData,
val proposalNetwork: Map<UserWallet, ProposalNetwork>,
val proposalAccountNetwork: Map<AccountId, ProposalNetwork>?,
val securityStatus: CheckDAppResult,
) {
data class ProposalNetwork(
val wallet: UserWallet,
val account: Account?,
val missingRequired: Set<Network>,
val required: Set<Network>,
val available: Set<Network>,

View file

@ -186,6 +186,7 @@ internal class WcPairModel @Inject constructor(
WcSessionApprove(
wallet = selectedUserWalletFlow.value,
network = enabledAvailableNetworks + proposalNetwork.required,
account = null, // todo account
),
)
}