Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-07 18:18:27 +07:00
parent fc8fa8ff8d
commit bcc957e249
34 changed files with 898 additions and 6 deletions

View file

@ -13,6 +13,7 @@ dependencies {
/* Project - Domain */
implementation(projects.domain.walletConnect)
implementation(projects.domain.walletConnect.models)
implementation(projects.domain.wallets.models)
/* Project - Data */
@ -25,6 +26,11 @@ dependencies {
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
/* Reown - WalletConnect */
implementation(deps.reownCore)
implementation(deps.reownWeb3)
/* Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
}

View file

@ -0,0 +1,38 @@
package com.tangem.data.walletconnect
import com.reown.walletkit.client.Wallet
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import timber.log.Timber
internal class DefaultWcSessionsManager : WcSessionsManager, WcSdkObserver {
private val _sessions = MutableSharedFlow<Map<UserWalletId, List<WcSession>>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val sessions get() = _sessions.distinctUntilChanged()
override suspend fun saveSessions(userWalletId: UserWalletId, session: WcSession) {
TODO("Not yet implemented")
}
override suspend fun removeSessions(userWalletId: UserWalletId, session: WcSession) {
TODO("Not yet implemented")
}
override suspend fun findSessionByTopic(topic: String): WcSession? {
TODO("Not yet implemented")
}
override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) {
// Triggered when the session is deleted by the peer
Timber.i("onSessionDelete: $sessionDelete")
// todo (wc) find session, delete and update sessions Flow
}
}

View file

@ -0,0 +1,115 @@
package com.tangem.data.walletconnect.initialize
import android.app.Application
import com.reown.android.Core
import com.reown.android.CoreClient
import com.reown.android.relay.ConnectionType
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.data.walletconnect.DefaultWcSessionsManager
import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase
import com.tangem.data.walletconnect.request.DefaultWcRequestService
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
import timber.log.Timber
internal class DefaultWcInitializeUseCase(
private val application: Application,
private val sessionsManager: DefaultWcSessionsManager,
private val networkService: DefaultWcRequestService,
private val wcPairFlow: DefaultWcPairUseCase,
) : WcInitializeUseCase {
private val wcSdkObservers = mutableSetOf<WcSdkObserver>(
sessionsManager,
networkService,
wcPairFlow,
)
override fun init(projectId: String) {
val relayUrl = "relay.walletconnect.com"
val serverUrl = "wss://$relayUrl?projectId=$projectId"
val connectionType = ConnectionType.AUTOMATIC
val appMetaData = Core.Model.AppMetaData(
name = "Tangem",
description = "Tangem Wallet",
url = "tangem.com",
icons = listOf(
"https://user-images.githubusercontent.com/24321494/124071202-72a00900-da58-11eb-935a-dcdab21de52b.png",
),
redirect = "kotlin-wallet-wc:/request", // Custom Redirect URI
)
CoreClient.initialize(
relayServerUrl = serverUrl,
connectionType = connectionType,
application = application,
metaData = appMetaData,
) { error ->
Timber.e("Error while initializing client: $error")
}
WalletKit.initialize(
Wallet.Params.Init(core = CoreClient),
onSuccess = {
val walletDelegate = defineWalletDelegate()
WalletKit.setWalletDelegate(walletDelegate)
},
onError = { error ->
Timber.e("Error while initializing Web3Wallet: $error")
},
)
}
private fun defineWalletDelegate() = object : WalletKit.WalletDelegate {
override val onSessionAuthenticate: ((Wallet.Model.SessionAuthenticate, Wallet.Model.VerifyContext) -> Unit)?
get() = super.onSessionAuthenticate
override fun onConnectionStateChange(state: Wallet.Model.ConnectionState) {
wcSdkObservers.forEach { it.onConnectionStateChange(state) }
}
override fun onError(error: Wallet.Model.Error) {
wcSdkObservers.forEach { it.onError(error) }
}
override fun onProposalExpired(proposal: Wallet.Model.ExpiredProposal) {
wcSdkObservers.forEach { it.onProposalExpired(proposal) }
}
override fun onRequestExpired(request: Wallet.Model.ExpiredRequest) {
wcSdkObservers.forEach { it.onRequestExpired(request) }
}
override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) {
wcSdkObservers.forEach { it.onSessionDelete(sessionDelete) }
}
override fun onSessionExtend(session: Wallet.Model.Session) {
wcSdkObservers.forEach { it.onSessionExtend(session) }
}
override fun onSessionProposal(
sessionProposal: Wallet.Model.SessionProposal,
verifyContext: Wallet.Model.VerifyContext,
) {
wcSdkObservers.forEach { it.onSessionProposal(sessionProposal, verifyContext) }
}
override fun onSessionRequest(
sessionRequest: Wallet.Model.SessionRequest,
verifyContext: Wallet.Model.VerifyContext,
) {
wcSdkObservers.forEach { it.onSessionRequest(sessionRequest, verifyContext) }
}
override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) {
wcSdkObservers.forEach { it.onSessionSettleResponse(settleSessionResponse) }
}
override fun onSessionUpdateResponse(sessionUpdateResponse: Wallet.Model.SessionUpdateResponse) {
wcSdkObservers.forEach { it.onSessionUpdateResponse(sessionUpdateResponse) }
}
}
}

View file

@ -0,0 +1,216 @@
package com.tangem.data.walletconnect.pair
import arrow.core.Either
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.WcSdkObserver
import com.tangem.data.walletconnect.utils.toOurModel
import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.model.WcSessionProposal
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionProposal
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.usecase.pair.WcPairState
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
import com.tangem.domain.wallets.models.UserWallet
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber
import kotlin.coroutines.resume
val unsupportedDApps = listOf("dYdX", "dYdX v4", "Apex Pro", "The Sandbox")
@Suppress("UnusedPrivateMember") // todo(wc) remove after add mapping
internal class DefaultWcPairUseCase(
private val sessionsManager: WcSessionsManager,
) : WcPairUseCase, WcSdkObserver {
private val onWalletSelect = Channel<UserWallet>()
private val onAccountSelect = Channel<Any>()
private val onCallTerminalAction = Channel<TerminalAction>()
private val onSessionProposal =
Channel<Pair<Wallet.Model.SessionProposal, Wallet.Model.VerifyContext>>(Channel.BUFFERED)
private val onSessionSettleResponse = Channel<Wallet.Model.SettledSessionResponse>(Channel.BUFFERED)
override fun pairFlow(uri: String, source: WcPairUseCase.Source, selectedWallet: UserWallet): Flow<WcPairState> =
flow {
emit(WcPairState.Loading)
// call sdk.pair and wait result, finish flow on error
walletKitPair(uri).onLeft { throwable ->
emit(WcPairState.Error(throwable))
return@flow
}
// wait for sdk onSessionProposal callback
val (sdkSessionProposal, verifyContext) = onSessionProposal.receiveAsFlow().map { (fist, second) ->
val ourCopy = WcSdkSessionProposal(
name = fist.name,
description = fist.description,
url = fist.url,
proposerPublicKey = fist.proposerPublicKey,
)
ourCopy to second
}.first { (sessionProposal, verifyContext) ->
true // todo(wc) check verifyContext? compare uri?
}
// check unsupported dApps, just local constant for now, finish if unsupported
if (sdkSessionProposal.name in unsupportedDApps) {
Timber.i("Unsupported DApp")
val error = WcPairState
.Error(RuntimeException("todo(wc) use domain exception")) // todo(wc) WalletConnectError.UnsupportedDApp
emit(error)
return@flow
}
// flow that collect terminal and all middle actions
val actionsFlow = channelFlow<TerminalAction> {
val userWallet = onWalletSelect.receiveAsFlow()
.stateIn(scope = this, started = SharingStarted.Lazily, initialValue = selectedWallet)
val accountSelect = onAccountSelect.receiveAsFlow()
.stateIn(scope = this, started = SharingStarted.Lazily, initialValue = Any())
// combine all middle variables and emit new ProposalState to main FlowCollector
combine(accountSelect, userWallet) { account, selectedWallet ->
buildProposalState(sdkSessionProposal, verifyContext, account, selectedWallet)
}.onEach { newProposalState -> this@flow.emit(newProposalState) }
.launchIn(this)
// collect terminal action and emit to this inner channelFlow, unlike combine above
onCallTerminalAction.receiveAsFlow()
.onEach { this.channel.send(it) }
.launchIn(this)
}
// wait first terminal action and continue WC pair flow
val sessionForApprove: WcSessionProposal = when (val terminalAction = actionsFlow.first()) {
is TerminalAction.Approve -> terminalAction.sessionForApprove
TerminalAction.Reject -> {
// non suspending WalletKit.rejectSession call
rejectSession(sdkSessionProposal)
return@flow
}
}
// start flow of approving in wc sdk
emit(WcPairState.Approving.Loading(sessionForApprove))
fun mapResult(either: Either<Throwable, WcSession>) =
WcPairState.Approving.Result(sessionForApprove, either)
// call sdk approve and wait result
walletKitApproveSession(sessionForApprove).onLeft { emit(mapResult(it.left())) }.onRight {
// wait for sdk callback
val result = when (val settledSession = onSessionSettleResponse.receiveAsFlow().first()) {
is Wallet.Model.SettledSessionResponse.Error -> mapResult(
// WalletConnectError.ExternalApprovalError(settledSession.errorMessage) todo(wc)
RuntimeException("todo(wc) use domain exception").left(),
)
is Wallet.Model.SettledSessionResponse.Result -> {
val newSession = settledSession.session.toDomain(sessionForApprove.wallet)
sessionsManager.saveSessions(sessionForApprove.wallet.walletId, newSession)
mapResult(newSession.right())
}
}
emit(result)
}
}
override fun onWalletSelect(selectedWallet: UserWallet) {
onWalletSelect.trySend(selectedWallet)
}
override fun onAccountSelect(account: Any) {
onAccountSelect.trySend(account)
}
override fun approve(sessionForApprove: WcSessionProposal) {
onCallTerminalAction.trySend(TerminalAction.Approve(sessionForApprove))
}
override fun reject() {
onCallTerminalAction.trySend(TerminalAction.Reject)
}
override fun onSessionProposal(
sessionProposal: Wallet.Model.SessionProposal,
verifyContext: Wallet.Model.VerifyContext,
) {
// Triggered when wallet receives the session proposal sent by a Dapp
Timber.i("sessionProposal: $sessionProposal")
onSessionProposal.trySend(sessionProposal to verifyContext)
}
override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) {
// Triggered when wallet receives the session settlement response from Dapp
Timber.i("onSessionSettleResponse: $settleSessionResponse")
onSessionSettleResponse.trySend(settleSessionResponse)
}
private suspend fun walletKitPair(uri: String): Either<Throwable, Unit> =
suspendCancellableCoroutine { continuation ->
WalletKit.pair(
params = Wallet.Params.Pair(uri),
onSuccess = {
Timber.i("Paired successfully: $it")
continuation.resume(Unit.right())
},
onError = {
Timber.e("Error while pairing: $it")
continuation.resume(it.throwable.left())
},
)
}
private suspend fun walletKitApproveSession(sessionForApprove: WcSessionProposal): Either<Throwable, Unit> =
suspendCancellableCoroutine { continuation ->
WalletKit.approveSession(
params = TODO("wc sdk model"),
onSuccess = {
Timber.i("Approved successfully: $it")
continuation.resume(Unit.right())
},
onError = {
Timber.e("Error while approving: $it")
continuation.resume(it.throwable.left())
},
)
}
private fun rejectSession(sessionProposal: WcSdkSessionProposal) {
WalletKit.rejectSession(
params = Wallet.Params.SessionReject(
proposerPublicKey = sessionProposal.proposerPublicKey,
reason = "",
),
onSuccess = {
Timber.i("Rejected successfully: $it")
},
onError = {
Timber.e("Error while rejecting: $it")
},
)
}
private fun buildProposalState(
sessionProposal: WcSdkSessionProposal,
verifyContext: Wallet.Model.VerifyContext,
selectedAccount: Any,
userWallet: UserWallet,
): WcPairState.Proposal {
// todo(wc) a lot of mapping from Wallet.Model.SessionProposal to our Blockchain, Network, ect
// todo(wc) check security status by Wallet.Model.VerifyContext or Blockaid, don't know for now
val mock: WcSessionProposal = TODO()
return WcPairState.Proposal(mock, Any())
}
private fun Wallet.Model.Session.toDomain(userWallet: UserWallet): WcSession = WcSession(
userWalletId = userWallet.walletId,
sdkModel = this.toOurModel(),
)
private sealed interface TerminalAction {
data class Approve(val sessionForApprove: WcSessionProposal) : TerminalAction
data object Reject : TerminalAction
}
}

View file

@ -0,0 +1,48 @@
package com.tangem.data.walletconnect.request
import com.reown.walletkit.client.Wallet
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.data.walletconnect.utils.toOurModel
import com.tangem.domain.walletconnect.model.WcMethod
import com.tangem.domain.walletconnect.model.WcRequest
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.request.WcRequestHandler
import com.tangem.domain.walletconnect.request.WcRequestService
import com.tangem.domain.walletconnect.respond.WcRespondService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
internal class DefaultWcRequestService(
private val sessionsManager: WcSessionsManager,
private val respondService: WcRespondService,
private val requestAdapters: List<WcRequestHandler<WcMethod>>,
private val scope: CoroutineScope, // todo(wc) is ok inject scope? featureScope like Decompose Model scope
) : WcRequestService, WcSdkObserver {
override val requests: MutableSharedFlow<WcRequest<*>> = MutableSharedFlow()
override fun onSessionRequest(
sessionRequest: Wallet.Model.SessionRequest,
verifyContext: Wallet.Model.VerifyContext,
) {
// Triggered when a Dapp sends SessionRequest to sign a transaction or a message
val sr = sessionRequest.toOurModel()
val method = sr.request.method
val params = sr.request.params
scope.launch {
val session = sessionsManager.findSessionByTopic(sr.topic)
val handler: WcRequestHandler<WcMethod>? = requestAdapters.firstOrNull { it.canHandle(method) }
val deserialized: WcMethod? = handler?.deserialize(method, params)
if (handler == null || deserialized == null || session == null) {
respondService.rejectRequest(sr, "UnsupportedMethod") // todo(wc) use our domain error
return@launch
}
val wcRequest = WcRequest(sr, session, deserialized)
handler.handle(wcRequest)
requests.emit(wcRequest)
}
}
}

View file

@ -0,0 +1,53 @@
package com.tangem.data.walletconnect.respond
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.domain.walletconnect.respond.WcRespondService
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
internal class DefaultWcRespondService : WcRespondService {
override suspend fun respond(request: WcSdkSessionRequest, response: String): Either<Throwable, Unit> =
suspendCancellableCoroutine { continuation ->
WalletKit.respondSessionRequest(
params = Wallet.Params.SessionRequestResponse(
sessionTopic = request.topic,
jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcResult(
id = request.request.id,
result = response,
),
),
onSuccess = {
continuation.resume(Unit.right())
},
onError = {
continuation.resume(it.throwable.left())
},
)
}
override suspend fun rejectRequest(request: WcSdkSessionRequest, message: String) =
suspendCancellableCoroutine { continuation ->
WalletKit.respondSessionRequest(
params = Wallet.Params.SessionRequestResponse(
sessionTopic = request.topic,
jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError(
id = request.request.id,
code = 0,
message = message,
),
),
onSuccess = {
continuation.resume(Unit.right())
},
onError = {
continuation.resume(it.throwable.left())
},
)
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.data.walletconnect.utils
import com.reown.walletkit.client.Wallet
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSession
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest.JSONRPCRequest
internal fun Wallet.Model.Session.toOurModel(): WcSdkSession = WcSdkSession(
topic = topic,
)
internal fun Wallet.Model.SessionRequest.toOurModel(): WcSdkSessionRequest = WcSdkSessionRequest(
topic = this.topic,
chainId = this.chainId,
request = this.request.toOurModel(),
)
internal fun Wallet.Model.SessionRequest.JSONRPCRequest.toOurModel(): JSONRPCRequest = JSONRPCRequest(
id = this.id,
method = this.method,
params = this.params,
)

View file

@ -0,0 +1,38 @@
package com.tangem.data.walletconnect.utils
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
internal interface WcSdkObserver : WalletKit.WalletDelegate {
override val onSessionAuthenticate: ((Wallet.Model.SessionAuthenticate, Wallet.Model.VerifyContext) -> Unit)?
get() = super.onSessionAuthenticate
override fun onConnectionStateChange(state: Wallet.Model.ConnectionState) {}
override fun onError(error: Wallet.Model.Error) {}
override fun onProposalExpired(proposal: Wallet.Model.ExpiredProposal) {}
override fun onRequestExpired(request: Wallet.Model.ExpiredRequest) {}
override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) {}
override fun onSessionExtend(session: Wallet.Model.Session) {}
override fun onSessionProposal(
sessionProposal: Wallet.Model.SessionProposal,
verifyContext: Wallet.Model.VerifyContext,
) {
}
override fun onSessionRequest(
sessionRequest: Wallet.Model.SessionRequest,
verifyContext: Wallet.Model.VerifyContext,
) {
}
override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) {}
override fun onSessionUpdateResponse(sessionUpdateResponse: Wallet.Model.SessionUpdateResponse) {}
}