Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-08 14:10:50 +07:00
parent c8c4f67018
commit dfaf4e4e77
16 changed files with 511 additions and 108 deletions

View file

@ -35,6 +35,7 @@ dependencies {
/* Tangem libraries */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
/* Reown - WalletConnect */
implementation(deps.reownCore) {
@ -47,4 +48,10 @@ dependencies {
/* Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
/* Tests */
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.turbine)
}

View file

@ -14,6 +14,7 @@ import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase
import com.tangem.data.walletconnect.request.DefaultWcRequestService
import com.tangem.data.walletconnect.request.WcMethodHandler
import com.tangem.data.walletconnect.respond.DefaultWcRespondService
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.datasource.di.SdkMoshi
@ -25,7 +26,6 @@ import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsReposit
import com.tangem.domain.walletconnect.repository.WalletConnectRepository
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.request.WcRequestService
import com.tangem.domain.walletconnect.respond.WcRespondService
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -131,9 +131,8 @@ internal object WalletConnectDataModule {
@Provides
@Singleton
fun wcEthNetwork(@SdkMoshi moshi: Moshi, respondService: WcRespondService): WcEthNetwork = WcEthNetwork(
fun wcEthNetwork(@SdkMoshi moshi: Moshi): WcEthNetwork = WcEthNetwork(
moshi = moshi,
respondService = respondService,
)
@Provides

View file

@ -8,10 +8,8 @@ import com.tangem.data.walletconnect.request.WcMethodHandler
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.domain.walletconnect.model.WcMethod
import com.tangem.domain.walletconnect.model.WcRequest
import com.tangem.domain.walletconnect.respond.WcRespondService
import com.tangem.domain.walletconnect.usecase.WcUseCase
import com.tangem.domain.walletconnect.usecase.WcUseCasesFlowProvider
import com.tangem.domain.walletconnect.usecase.ethereum.EthPersonalSignUseCase
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMethod
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMethod.SignMessage
import kotlinx.coroutines.channels.Channel
@ -19,7 +17,6 @@ import kotlinx.coroutines.flow.receiveAsFlow
internal class WcEthNetwork(
private val moshi: Moshi,
private val respondService: WcRespondService,
) : WcMethodHandler<WcEthMethod>, WcUseCasesFlowProvider, WcNamespaceConverter {
private val _useCases: Channel<WcUseCase> = Channel(Channel.BUFFERED)
@ -46,7 +43,7 @@ internal class WcEthNetwork(
override fun handle(wcRequest: WcRequest<WcMethod>) {
wcRequest as WcRequest<WcEthMethod>
val useCase = when (wcRequest.method) {
is SignMessage -> EthPersonalSignUseCase(wcRequest as WcRequest<SignMessage>, respondService)
is SignMessage -> TODO()
}
_useCases.trySend(useCase)
}

View file

@ -1,13 +1,13 @@
package com.tangem.data.walletconnect.request
import com.reown.walletkit.client.Wallet
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.data.walletconnect.utils.WcSdkSessionRequestConverter
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.WcRequestService
import com.tangem.domain.walletconnect.respond.WcRespondService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch

View file

@ -6,7 +6,6 @@ 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
@ -50,4 +49,19 @@ internal class DefaultWcRespondService : WcRespondService {
},
)
}
override fun rejectRequestNonBlock(request: WcSdkSessionRequest, message: String) {
WalletKit.respondSessionRequest(
params = Wallet.Params.SessionRequestResponse(
sessionTopic = request.topic,
jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError(
id = request.request.id,
code = 0,
message = message,
),
),
onSuccess = {},
onError = {},
)
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.data.walletconnect.respond
import arrow.core.Either
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
interface WcRespondService {
suspend fun respond(request: WcSdkSessionRequest, response: String): Either<Throwable, Unit>
suspend fun rejectRequest(request: WcSdkSessionRequest, message: String = ""): Either<Throwable, Unit>
fun rejectRequestNonBlock(request: WcSdkSessionRequest, message: String = "")
}

View file

@ -0,0 +1,65 @@
package com.tangem.data.walletconnect.sign
import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.domain.walletconnect.usecase.WcMethodUseCase
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
import com.tangem.domain.walletconnect.usecase.sign.WcSignUseCase
import kotlinx.coroutines.flow.FlowCollector
internal abstract class BaseWcSignUseCase<MiddleAction, SignModel> :
WcMethodUseCase,
WcSignUseCase.FinalAction,
FinalActionCollector<SignModel>,
MiddleActionCollector<MiddleAction, SignModel> {
abstract val respondService: WcRespondService
abstract val context: WcMethodUseCaseContext
override val session: WcSession get() = context.session
override val rawSdkRequest: WcSdkSessionRequest get() = context.rawSdkRequest
protected val delegate by lazy {
WcSignUseCaseDelegate(
finalActionCollector = this,
middleActionCollector = this,
)
}
override val onCancel: suspend (currentState: WcSignState<SignModel>) -> Unit = {
defaultReject()
}
override fun sign() = delegate.sign()
override fun cancel() = delegate.cancel()
protected fun middleAction(action: MiddleAction) = delegate.middleAction(action)
protected fun defaultReject() {
respondService.rejectRequestNonBlock(rawSdkRequest)
}
}
internal interface MiddleActionCollector<MiddleAction, SignModel> {
val onMiddleAction: OnMiddle<MiddleAction, SignModel> get() = { _, _ -> }
}
internal interface FinalActionCollector<SignModel> {
val onSign: OnSign<SignModel> get() = {}
val onCancel: OnCancel<SignModel> get() = {}
}
internal class WcMethodUseCaseContext(
val session: WcSession,
val rawSdkRequest: WcSdkSessionRequest,
)
internal typealias OnSign<SignModel> =
suspend FlowCollector<WcSignState<SignModel>>.(state: WcSignState<SignModel>) -> Unit
internal typealias OnCancel<SignModel> =
suspend (currentState: WcSignState<SignModel>) -> Unit
internal typealias OnMiddle<MiddleAction, SignModel> =
suspend FlowCollector<SignModel>.(currentState: WcSignState<SignModel>, middleAction: MiddleAction) -> Unit

View file

@ -0,0 +1,28 @@
package com.tangem.data.walletconnect.sign
import arrow.core.Either
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
import com.tangem.domain.walletconnect.usecase.sign.WcSignStep
object SignStateConverter {
internal fun <M> preSign(signModel: M) = WcSignState(signModel, WcSignStep.PreSign)
internal fun <M> signing(signModel: M) = WcSignState(signModel, WcSignStep.Signing)
internal fun <M> result(result: Either<Throwable, Unit>, signModel: M) =
WcSignState(signModel, WcSignStep.Result(result))
internal fun <M> WcSignState<M>.toPreSign(signModel: M = this.signModel) = copy(
signModel = signModel,
domainStep = WcSignStep.PreSign,
)
internal fun <M> WcSignState<M>.toSigning(signModel: M = this.signModel) = copy(
domainStep = WcSignStep.Signing,
signModel = signModel,
)
internal fun <M> WcSignState<M>.toResult(result: Either<Throwable, Unit>, signModel: M = this.signModel) = copy(
domainStep = WcSignStep.Result(result),
signModel = signModel,
)
}

View file

@ -0,0 +1,91 @@
package com.tangem.data.walletconnect.sign
import arrow.core.left
import com.tangem.data.walletconnect.sign.SignStateConverter.toPreSign
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
import com.tangem.domain.walletconnect.usecase.sign.WcSignStep
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
internal class WcSignUseCaseDelegate<MiddleAction, SignModel>(
private val finalActionCollector: FinalActionCollector<SignModel>,
private val middleActionCollector: MiddleActionCollector<MiddleAction, SignModel>,
) : FinalActionCollector<SignModel> by finalActionCollector,
MiddleActionCollector<MiddleAction, SignModel> by middleActionCollector {
private val middleActionsChannel = Channel<MiddleAction>()
private val finalActionsChannel = Channel<Action>()
fun cancel() {
finalActionsChannel.trySend(Action.Cancel)
}
fun sign() {
finalActionsChannel.trySend(Action.Sign)
}
fun middleAction(action: MiddleAction) {
middleActionsChannel.trySend(action)
}
operator fun invoke(initModel: SignModel) = channelFlow {
val state = MutableStateFlow(WcSignState(initModel, WcSignStep.PreSign))
state
.onEach { newState -> channel.send(newState) }
.launchIn(this)
fun listenMiddle() = middleActionsChannel.receiveAsFlow()
.buffer()
.transform { middleActions -> this.onMiddleAction(state.value, middleActions) }
.onEach { updatedModel -> state.update { it.toPreSign(updatedModel) } }
.launchIn(this)
var listenMiddleJob: Job = listenMiddle()
fun signFlow() = flow { onSign(state.updateAndGet { it.toSigning() }) }
.onEach { newState -> state.update { newState } }
.catch { exception ->
val errorResult = state.value.toResult(exception.left())
state.update { errorResult }
}
var signJob: Job? = null
finalActionsChannel.receiveAsFlow()
.transformLatest<Action, Unit> { finalAction ->
when (finalAction) {
Action.Cancel -> {
onCancel.invoke(state.value)
channel.close()
}
Action.Sign -> {
val isSigningNow = signJob?.isActive == true
if (isSigningNow) return@transformLatest
listenMiddleJob.cancel()
signJob = launch {
signFlow().collect()
listenMiddleJob = listenMiddle()
}
}
}
}
.launchIn(this)
/**
* keep flow running to attempt re-signing after an error
* or do something after a successful sign
*/
awaitClose()
}
sealed interface Action {
data object Cancel : Action
data object Sign : Action
}
}

View file

@ -0,0 +1,254 @@
package com.tangem.domain.walletconnect
import app.cash.turbine.test
import arrow.core.left
import arrow.core.right
import com.tangem.data.walletconnect.sign.FinalActionCollector
import com.tangem.data.walletconnect.sign.MiddleActionCollector
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning
import com.tangem.data.walletconnect.sign.WcSignUseCaseDelegate
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
import com.tangem.domain.walletconnect.usecase.sign.WcSignStep
import io.mockk.every
import io.mockk.mockk
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
internal class WcSignUseCaseDelegateTest {
private val middleActionCollector = mockk<MiddleActionCollector<TestMiddleAction, TestSignModel>>()
private val finalActionCollector = mockk<FinalActionCollector<TestSignModel>>()
private val useCase = WcSignUseCaseDelegate(
finalActionCollector = finalActionCollector,
middleActionCollector = middleActionCollector,
)
private val initSignModel = TestSignModel()
private val initState = WcSignState(initSignModel, WcSignStep.PreSign)
private val signing = initState.toSigning()
private val result = signing.toResult(Unit.right())
private val testException = RuntimeException("test")
private val successSign: suspend FlowCollector<WcSignState<TestSignModel>>.(
currentState: WcSignState<TestSignModel>,
) -> Unit = { state ->
delay(2)
emit(state.toResult(Unit.right()))
}
private val failedSign: suspend FlowCollector<WcSignState<TestSignModel>>.(
currentState: WcSignState<TestSignModel>,
) -> Unit
get() = { state ->
delay(2)
emit(state.toResult(testException.left()))
}
@Before
fun setup() {
every { middleActionCollector.onMiddleAction } returns { _, _ -> }
every { finalActionCollector.onSign } returns { }
every { finalActionCollector.onCancel } returns { }
}
@Test
fun `invoke and keep flow running`() = runTest {
every { finalActionCollector.onSign } returns successSign
useCase.invoke(initSignModel).test {
assertEquals(initState, awaitItem())
expectNoEvents()
}
}
@Test
fun `success sign, keep flow running`() = runTest {
every { finalActionCollector.onSign } returns successSign
useCase.invoke(initModel = initSignModel).test {
assertEquals(initState, awaitItem())
useCase.sign()
assertEquals(signing, awaitItem())
assertEquals(result, awaitItem())
expectNoEvents()
}
}
@Test
fun `failed sign, keep flow running`() = runTest {
val failedResult = signing.toResult(testException.left())
every { finalActionCollector.onSign } returns failedSign
useCase.invoke(initSignModel).test {
assertEquals(initState, awaitItem())
useCase.sign()
assertEquals(signing, awaitItem())
assertEquals(failedResult, awaitItem())
expectNoEvents()
}
}
@Test
fun `failed sign and catch unknown exception`() = runTest {
val exception = RuntimeException("asd")
val expectedErrorState = signing.toResult(exception.left())
every { finalActionCollector.onSign } returns {
delay(2)
throw exception
}
useCase.invoke(initSignModel).test {
assertEquals(initState, awaitItem())
useCase.sign()
assertEquals(signing, awaitItem())
assertEquals(expectedErrorState, awaitItem())
expectNoEvents()
}
}
@Test
fun `interrupt signing and complete flow on cancel call`() = runTest {
every { finalActionCollector.onSign } returns {
delay(5)
emit(result)
}
useCase.invoke(initSignModel).test {
assertEquals(initState, awaitItem())
useCase.sign()
assertEquals(signing, awaitItem())
delay(2)
useCase.cancel()
awaitComplete()
}
}
@Test
fun `ignore multi time sign call till signed`() = runTest {
var count = 0
val startLoading = WcSignState(TestSignModel("startLoading 1"), WcSignStep.Signing)
val startLoading2 = WcSignState(TestSignModel("startLoading 2"), WcSignStep.Signing)
val expectedSignResult = result
every { finalActionCollector.onSign } returns {
// should emit single time in this test
emit(if (count % 2 == 0) startLoading else startLoading2)
count = count.inc()
delay(10)
emit(expectedSignResult)
}
useCase.invoke(initSignModel).test {
useCase.sign()
delay(2)
assertEquals(startLoading, expectMostRecentItem())
// should ignore
useCase.sign()
delay(2)
expectNoEvents()
// should ignore
useCase.sign()
expectNoEvents()
delay(8)
assertEquals(expectedSignResult, expectMostRecentItem())
expectNoEvents()
}
}
@Test
fun `ignore middle actions while signing, on failed collect middle actions again`() = runTest {
val firstTextMode = TestSignModel(TestMiddleAction.One().newTestStr)
val firstMiddleUpdate = WcSignState(
signModel = firstTextMode,
domainStep = WcSignStep.PreSign,
)
val startLoading = firstMiddleUpdate.toSigning()
val failedSign = startLoading.toResult(testException.left())
val thirdMiddleUpdate = WcSignState(
signModel = TestSignModel(TestMiddleAction.Three().newTestStr),
domainStep = WcSignStep.PreSign,
)
every { finalActionCollector.onSign } returns {
delay(6)
emit(failedSign)
}
every { middleActionCollector.onMiddleAction } returns { currentState, middleAction ->
emit(currentState.signModel.copy(testStr = middleAction.newTestStr))
}
useCase.invoke(initSignModel).test {
delay(2)
useCase.middleAction(TestMiddleAction.One())
assertEquals(firstMiddleUpdate, expectMostRecentItem())
useCase.sign()
assertEquals(startLoading, awaitItem())
// should ignore
delay(2)
useCase.middleAction(TestMiddleAction.Two())
delay(3)
assertEquals(failedSign, awaitItem())
// continue listen
delay(2)
useCase.middleAction(TestMiddleAction.Three())
assertEquals(thirdMiddleUpdate, awaitItem())
}
}
@Test
fun `buffered middle actions and drop on sign call`() = runTest {
val firstTextMode = TestSignModel(TestMiddleAction.One().newTestStr)
val expectedFirst = initState.copy(signModel = firstTextMode)
val expectedSecond = initState.copy(signModel = TestSignModel(TestMiddleAction.Two().newTestStr))
every { finalActionCollector.onSign } returns successSign
every { middleActionCollector.onMiddleAction } returns { currentState, middleAction ->
emit(currentState.signModel.copy(testStr = middleAction.newTestStr))
delay(4)
}
useCase.invoke(initSignModel).test {
assertEquals(initState, awaitItem())
useCase.middleAction(TestMiddleAction.One())
useCase.middleAction(TestMiddleAction.Two())
// must be dropped
useCase.middleAction(TestMiddleAction.Three())
// 0 - 4 -> "one" is emitted
// 4 - 8 -> "two" is emitted
// 8 - 12 -> "Signing" is emitted, "three" ignored
delay(2)
assertEquals(expectedFirst, awaitItem())
delay(4)
assertEquals(expectedSecond, awaitItem())
useCase.sign()
delay(4)
assertEquals(expectedSecond.toSigning(), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
internal data class TestSignModel(val testStr: String = "testStr")
internal sealed interface TestMiddleAction {
val newTestStr: String
data class One(override val newTestStr: String = "Middle Action One") : TestMiddleAction
data class Two(override val newTestStr: String = "Middle Action Two") : TestMiddleAction
data class Three(override val newTestStr: String = "Middle Action Three") : TestMiddleAction
}
}