Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-07 16:29:50 +07:00
parent 3dbaff9f06
commit 776d00eb57
8 changed files with 94 additions and 46 deletions

View file

@ -48,7 +48,7 @@ internal class DefaultWcInitializeUseCase(
application = application,
metaData = appMetaData,
) { error ->
Timber.e("Error while initializing client: $error")
Timber.tag(WC_TAG).e("Error while initializing client: $error")
}
WalletKit.initialize(
@ -60,7 +60,7 @@ internal class DefaultWcInitializeUseCase(
Timber.tag(WC_TAG).i("onWcSdkInit")
},
onError = { error ->
Timber.e("Error while initializing Web3Wallet: $error")
Timber.tag(WC_TAG).e("Error while initializing Web3Wallet: $error")
},
)
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.walletconnect.pair
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.Wallet.Model.Namespace
import com.tangem.data.common.currency.isCustomCoin
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
@ -33,6 +34,8 @@ internal class AssociateNetworksDelegate(
val userWallets = getWallets.invokeSync().filter { it.isMultiCurrency }
val requiredNamespaces: Set<String> = sessionProposal.requiredNamespaces.setOfChainId()
val optionalNamespaces: Set<String> = sessionProposal.optionalNamespaces.setOfChainId()
// remove duplicates
.subtract(requiredNamespaces)
return userWallets
.associateWith { wallet -> mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces) }
@ -58,7 +61,8 @@ internal class AssociateNetworksDelegate(
return@forEach
}
val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id }
if (walletNetwork == null) {
if (walletNetwork == null || isCustomCoin(walletNetwork)) {
missingRequired.add(wcNetwork)
} else {
required.add(walletNetwork)
@ -68,7 +72,7 @@ internal class AssociateNetworksDelegate(
val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) }
?: return@forEach
val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id }
if (walletNetwork != null) {
if (walletNetwork != null && !isCustomCoin(walletNetwork)) {
available.add(walletNetwork)
} else {
notAdded.add(wcNetwork)

View file

@ -56,7 +56,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
// check unsupported dApps, just local constant for now, finish if unsupported
if (sdkSessionProposal.name in unsupportedDApps) {
Timber.tag(WC_TAG).i("Unsupported DApp ${sdkSessionProposal.name}")
val error = WcPairState.Error(WcPairError.UnsupportedDomain)
val error = WcPairState.Error(WcPairError.UnsupportedDApp)
emit(error)
return@flow
}

View file

@ -5,59 +5,94 @@ import arrow.core.left
import arrow.core.right
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.domain.walletconnect.model.WcPairError
import kotlinx.coroutines.async
import com.tangem.domain.walletconnect.model.WcPairError.ApprovalFailed
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import timber.log.Timber
import kotlin.coroutines.resume
import kotlin.time.Duration.Companion.seconds
internal class WcPairSdkDelegate : WcSdkObserver {
private val onSessionProposal = Channel<Wallet.Model.SessionProposal>()
private val onSdkErrorCallback = Channel<Wallet.Model.Error>()
private val onSessionSettleResponse = Channel<Wallet.Model.SettledSessionResponse>()
suspend fun pair(url: String): Either<WcPairError, Wallet.Model.SessionProposal> = coroutineScope {
suspend fun proposalCallback() = onSessionProposal
.receiveAsFlow()
.first()
val proposalCallback = async { withTimeout(CALLBACK_TIMEOUT.seconds) { proposalCallback() } }
val pairCall = async { sdkPair(url) }
val proposal = async { withTimeout(20.seconds) { proposalCallback() } }
pairCall.await().onLeft {
proposal.cancel()
proposalCallback.cancel()
return@coroutineScope it.left()
}
proposal.await().right()
proposalCallback.await()
}
private suspend fun proposalCallback() = callbackFlow {
// wait first onSessionProposal callback
launch {
val sessionProposal = onSessionProposal.receiveAsFlow().first()
trySend(sessionProposal.right())
channel.close()
}
// OR
// wait first onError callback
launch {
val error = onSdkErrorCallback.receiveAsFlow().first()
trySend(error.throwable.toPairError().left())
channel.close()
}
awaitClose()
}.first()
suspend fun approve(
sessionApprove: Wallet.Params.SessionApprove,
): Either<WcPairError, Wallet.Model.SettledSessionResponse.Result> = coroutineScope {
suspend fun approveCallback() = onSessionSettleResponse
.receiveAsFlow()
.first()
val approveCallback = async { withTimeout(CALLBACK_TIMEOUT.seconds) { approveCallback() } }
val approveCall = async { sdkApprove(sessionApprove) }
val approveCallback = async { withTimeout(20.seconds) { approveCallback() } }
approveCall.await()
.onLeft {
approveCallback.cancel()
return@coroutineScope it.left()
}
when (val result = approveCallback.await()) {
is Wallet.Model.SettledSessionResponse.Result -> result.right()
is Wallet.Model.SettledSessionResponse.Error ->
WcPairError.ApprovalFailed(result.errorMessage).left()
}
return@coroutineScope approveCallback.await().fold(
ifLeft = { it.left() },
ifRight = { result ->
when (result) {
is Wallet.Model.SettledSessionResponse.Result -> result.right()
is Wallet.Model.SettledSessionResponse.Error ->
ApprovalFailed(result.errorMessage).left()
}
},
)
}
private suspend fun approveCallback() = callbackFlow<Either<WcPairError, Wallet.Model.SettledSessionResponse>> {
// wait first onSessionSettleResponse callback
launch {
val settledSessionResponse = onSessionSettleResponse.receiveAsFlow().first()
trySend(settledSessionResponse.right())
channel.close()
}
// OR
// wait first onError callback
launch {
val error = onSdkErrorCallback.receiveAsFlow().first()
trySend(error.throwable.toApproveError())
channel.close()
}
awaitClose()
}.first()
fun rejectSession(proposerPublicKey: String) {
Timber.tag(WC_TAG).i("reject session proposerPublicKey = $proposerPublicKey")
WalletKit.rejectSession(
params = Wallet.Params.SessionReject(
proposerPublicKey = proposerPublicKey,
@ -68,6 +103,10 @@ internal class WcPairSdkDelegate : WcSdkObserver {
)
}
override fun onError(error: Wallet.Model.Error) {
onSdkErrorCallback.trySend(error)
}
override fun onSessionProposal(
sessionProposal: Wallet.Model.SessionProposal,
verifyContext: Wallet.Model.VerifyContext,
@ -85,8 +124,8 @@ internal class WcPairSdkDelegate : WcSdkObserver {
return suspendCancellableCoroutine { continuation ->
WalletKit.approveSession(
params = sessionApprove,
onSuccess = { continuation.resume(Unit.right()) },
onError = { continuation.resume(it.throwable.toApproveError()) },
onSuccess = { if (continuation.isActive) continuation.resume(Unit.right()) },
onError = { if (continuation.isActive) continuation.resume(it.throwable.toApproveError()) },
)
}
}
@ -95,8 +134,8 @@ internal class WcPairSdkDelegate : WcSdkObserver {
return suspendCancellableCoroutine { continuation ->
WalletKit.pair(
params = Wallet.Params.Pair(uri),
onSuccess = { continuation.resume(Unit.right()) },
onError = { continuation.resume(it.throwable.toPairError().left()) },
onSuccess = { if (continuation.isActive) continuation.resume(Unit.right()) },
onError = { if (continuation.isActive) continuation.resume(it.throwable.toPairError().left()) },
)
}
}
@ -109,7 +148,12 @@ internal class WcPairSdkDelegate : WcSdkObserver {
private fun Throwable.toApproveError() = WcPairError.ApprovalFailed(this.localizedMessage.orEmpty()).left()
companion object {
private const val CALLBACK_TIMEOUT = 30
// com.reown.android.pairing.engine.domain.PairingEngine.pair
private val pairingExpiredMessages = listOf("Pairing URI expired", "Pairing expired")
private val pairingExpiredMessages = listOf(
"Pairing URI expired",
"Pairing expired",
"No proposal or pending session authenticate request for pairing topic",
)
}
}

View file

@ -24,10 +24,12 @@ internal class DefaultWcRespondService : WcRespondService {
),
),
onSuccess = {
if (continuation.isCompleted) return@respondSessionRequest
Timber.tag(WC_TAG).i("Successful respond for request $request")
continuation.resume(Unit.right())
},
onError = {
if (continuation.isCompleted) return@respondSessionRequest
Timber.tag(WC_TAG).e(it.throwable, "Failed respond for request $request")
continuation.resume(it.throwable.left())
},

View file

@ -35,7 +35,6 @@ internal class DefaultWcSessionsManager(
private val onSessionDelete = Channel<Wallet.Model.SessionDelete>(capacity = Channel.BUFFERED)
private val oneTimeMigration = MutableStateFlow(true)
private val oneTimeSessionExtend = MutableStateFlow(true)
override val sessions: Flow<Map<UserWallet, List<WcSession>>>
get() = combine(getWallets(), store.sessions) { wallets, inStore -> wallets to inStore }
@ -50,10 +49,6 @@ internal class DefaultWcSessionsManager(
val associatedSessions: List<WcSession> = associate(inSdk, inStore, wallets)
val someRemove = removeUnknownSessions(inStore, associatedSessions)
if (someRemove) return@transform // ignore emit, wait next one
if (oneTimeSessionExtend.value) {
oneTimeSessionExtend.value = false
scope.launch { extendAliveSessions(associatedSessions) }
}
emit(associatedSessions.groupBy { it.wallet })
}
.distinctUntilChanged()
@ -61,8 +56,8 @@ internal class DefaultWcSessionsManager(
override fun onWcSdkInit() {
oneTimeMigration.value = true
oneTimeSessionExtend.value = true
listenOnSessionDelete()
extendSessions()
}
override suspend fun saveSession(session: WcSession) {
@ -154,17 +149,20 @@ internal class DefaultWcSessionsManager(
return haveSomeUnknown
}
private suspend fun extendAliveSessions(sessions: List<WcSession>) = coroutineScope {
val jobs = sessions.map { launch { sdkSessionExtend(it.sdkModel.topic) } }
jobs.joinAll()
private fun extendSessions() {
scope.launch(dispatchers.io) {
val topics: List<String> = WalletKit.getListOfActiveSessions().map { it.topic }
val jobs = topics.map { topic -> launch { sdkSessionExtend(topic) } }
jobs.joinAll()
}
}
private suspend fun sdkDisconnectSession(topic: String): Either<Throwable, Unit> {
return suspendCancellableCoroutine { continuation ->
WalletKit.disconnectSession(
params = Wallet.Params.SessionDisconnect(topic),
onSuccess = { continuation.resume(Unit.right()) },
onError = { continuation.resume(it.throwable.left()) },
onSuccess = { if (continuation.isActive) continuation.resume(Unit.right()) },
onError = { if (continuation.isActive) continuation.resume(it.throwable.left()) },
)
}
}
@ -173,8 +171,8 @@ internal class DefaultWcSessionsManager(
return suspendCancellableCoroutine { continuation ->
WalletKit.extendSession(
params = Wallet.Params.SessionExtend(topic),
onSuccess = { continuation.resume(Unit.right()) },
onError = { continuation.resume(it.throwable.left()) },
onSuccess = { if (continuation.isActive) continuation.resume(Unit.right()) },
onError = { if (continuation.isActive) continuation.resume(it.throwable.left()) },
)
}
}

View file

@ -196,7 +196,7 @@ internal class DefaultWcPairUseCaseTest {
@Test
fun `success pair and reject unsupported dApp`() = runTest {
coEvery { sdkDelegate.pair(url) } returns unsupportedSdkProposal.right()
val unsupportedDAppError = WcPairState.Error(WcPairError.UnsupportedDomain)
val unsupportedDAppError = WcPairState.Error(WcPairError.UnsupportedDApp)
val useCase = useCaseFactory()
useCase.invoke().test {

View file

@ -8,7 +8,7 @@ sealed class WcPairError(
data class UriAlreadyUsed(override val message: String) : WcPairError("107 001 001")
data class PairingFailed(override val message: String) : WcPairError("107 001 002")
data object InvalidDomainURL : WcPairError("107 001 003")
data object UnsupportedDomain : WcPairError("107 001 004")
data object UnsupportedDApp : WcPairError("107 001 004")
data class UnsupportedBlockchains(val chains: Set<String>) : WcPairError("107 001 005")
data object InvalidConnectionRequest : WcPairError("107 002 001")