Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-16 14:04:46 +03:00
parent c8ded3b7d5
commit bff165de00
7 changed files with 253 additions and 54 deletions

View file

@ -496,10 +496,10 @@ internal class DefaultUserWalletsListRepository(
is HotWalletPasswordRequester.Result.EnteredPassword -> { is HotWalletPasswordRequester.Result.EnteredPassword -> {
val decrypted = block(result.password.value) val decrypted = block(result.password.value)
if (decrypted == null) { if (decrypted == null) {
passwordRequester.wrongPassword() passwordRequester.wrongPassword(attemptRequest)
requestPasswordRecursive(hotWalletId, block, biometryFallback) requestPasswordRecursive(hotWalletId, block, biometryFallback)
} else { } else {
passwordRequester.successfulAuthentication() passwordRequester.successfulAuthentication(attemptRequest)
passwordRequester.dismiss() passwordRequester.dismiss()
decrypted.right() decrypted.right()
} }
@ -507,7 +507,7 @@ internal class DefaultUserWalletsListRepository(
HotWalletPasswordRequester.Result.UseBiometry -> { HotWalletPasswordRequester.Result.UseBiometry -> {
biometryFallback() biometryFallback()
.onRight { .onRight {
passwordRequester.successfulAuthentication() passwordRequester.successfulAuthentication(attemptRequest)
passwordRequester.dismiss() passwordRequester.dismiss()
} }
.map { null } .map { null }

View file

@ -28,6 +28,11 @@ class DefaultHotWalletAccessor @Inject constructor(
private val scope: AppCoroutineScope, private val scope: AppCoroutineScope,
) : HotWalletAccessor { ) : HotWalletAccessor {
private data class RequestedAuth(
val auth: HotAuth,
val attemptRequest: HotWalletPasswordRequester.AttemptRequest?,
)
private val contextualUnlockHotWallet: ConcurrentHashMap<HotWalletId, UnlockHotWallet?> = ConcurrentHashMap() private val contextualUnlockHotWallet: ConcurrentHashMap<HotWalletId, UnlockHotWallet?> = ConcurrentHashMap()
override suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> = override suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> =
@ -84,8 +89,8 @@ class DefaultHotWalletAccessor @Inject constructor(
private suspend fun <T> hotSdkRequest(hotWalletId: HotWalletId, block: suspend (unlock: UnlockHotWallet) -> T): T { private suspend fun <T> hotSdkRequest(hotWalletId: HotWalletId, block: suspend (unlock: UnlockHotWallet) -> T): T {
val isAccessCodeRequired = isAccessCodeRequired() val isAccessCodeRequired = isAccessCodeRequired()
val auth = when (hotWalletId.authType) { val requestedAuth = when (hotWalletId.authType) {
HotWalletId.AuthType.NoPassword -> HotAuth.NoAuth HotWalletId.AuthType.NoPassword -> RequestedAuth(HotAuth.NoAuth, attemptRequest = null)
HotWalletId.AuthType.Password -> requestPassword( HotWalletId.AuthType.Password -> requestPassword(
hotWalletId = hotWalletId, hotWalletId = hotWalletId,
hasBiometry = false, hasBiometry = false,
@ -97,35 +102,32 @@ class DefaultHotWalletAccessor @Inject constructor(
hasBiometry = false, hasBiometry = false,
) )
} else { } else {
HotAuth.Biometry RequestedAuth(HotAuth.Biometry, attemptRequest = null)
} }
} }
} }
return runCatchingSdkErrors(hotWalletId, auth) { return runCatchingSdkErrors(hotWalletId, requestedAuth) {
block(UnlockHotWallet(hotWalletId, it)).also { block(UnlockHotWallet(hotWalletId, it))
hotWalletPasswordRequester.successfulAuthentication()
hotWalletPasswordRequester.dismiss()
}
} }
} }
private suspend fun <T> runCatchingSdkErrors( private suspend fun <T> runCatchingSdkErrors(
hotWalletId: HotWalletId, hotWalletId: HotWalletId,
auth: HotAuth, requestedAuth: RequestedAuth,
block: suspend (auth: HotAuth) -> T, block: suspend (auth: HotAuth) -> T,
): T { ): T {
return runCatchingWrongPassInternal( return runCatchingWrongPassInternal(
hotWalletId = hotWalletId, hotWalletId = hotWalletId,
originalAuth = auth, originalAuth = requestedAuth,
auth = auth, requestedAuth = requestedAuth,
block = { blockAuth -> block = { blockAuth ->
val result = block(blockAuth) val result = block(blockAuth)
// Update biometry auth if the original auth was password // Update biometry auth if the original auth was password
updateBiometryAuthIfNeeded( updateBiometryAuthIfNeeded(
hotWalletId = hotWalletId, hotWalletId = hotWalletId,
originalAuth = auth, originalAuth = requestedAuth.auth,
) )
result result
@ -161,13 +163,19 @@ class DefaultHotWalletAccessor @Inject constructor(
private suspend fun <T> runCatchingWrongPassInternal( private suspend fun <T> runCatchingWrongPassInternal(
hotWalletId: HotWalletId, hotWalletId: HotWalletId,
originalAuth: HotAuth, originalAuth: RequestedAuth,
auth: HotAuth, requestedAuth: RequestedAuth,
block: suspend (auth: HotAuth) -> T, block: suspend (auth: HotAuth) -> T,
): T = runSuspendCatching { ): T = runSuspendCatching {
block(auth) block(requestedAuth.auth).also {
val request = requestedAuth.attemptRequest
if (request != null) {
hotWalletPasswordRequester.successfulAuthentication(request)
}
hotWalletPasswordRequester.dismiss()
}
}.getOrElse { exception -> }.getOrElse { exception ->
if (auth is HotAuth.Biometry && (exception.isBiometryError() || exception.isBiometryReset())) { if (requestedAuth.auth is HotAuth.Biometry && (exception.isBiometryError() || exception.isBiometryReset())) {
val shouldRetryBiometry = exception is TangemSdkError.AuthenticationCanceled val shouldRetryBiometry = exception is TangemSdkError.AuthenticationCanceled
// fallback to password if biometry fails // fallback to password if biometry fails
@ -179,7 +187,7 @@ class DefaultHotWalletAccessor @Inject constructor(
return@getOrElse runCatchingWrongPassInternal( return@getOrElse runCatchingWrongPassInternal(
hotWalletId = hotWalletId, hotWalletId = hotWalletId,
originalAuth = originalAuth, originalAuth = originalAuth,
auth = passAuth, requestedAuth = passAuth,
block = block, block = block,
) )
} }
@ -190,29 +198,30 @@ class DefaultHotWalletAccessor @Inject constructor(
// If the exception is a wrong password, we need to request the password again // If the exception is a wrong password, we need to request the password again
hotWalletPasswordRequester.wrongPassword() requestedAuth.attemptRequest?.let { hotWalletPasswordRequester.wrongPassword(it) }
val passResult = requestPassword( val passResult = requestPassword(
hotWalletId = hotWalletId, hotWalletId = hotWalletId,
hasBiometry = originalAuth is HotAuth.Biometry, hasBiometry = originalAuth.auth is HotAuth.Biometry,
) )
runCatchingWrongPassInternal( runCatchingWrongPassInternal(
hotWalletId = hotWalletId, hotWalletId = hotWalletId,
originalAuth = originalAuth, originalAuth = originalAuth,
auth = passResult, requestedAuth = passResult,
block = block, block = block,
) )
} }
private suspend fun requestPassword(hotWalletId: HotWalletId, hasBiometry: Boolean): HotAuth { private suspend fun requestPassword(hotWalletId: HotWalletId, hasBiometry: Boolean): RequestedAuth {
val attemptRequest = HotWalletPasswordRequester.AttemptRequest( val attemptRequest = HotWalletPasswordRequester.AttemptRequest(
hotWalletId = hotWalletId, hotWalletId = hotWalletId,
authMode = false, authMode = false,
hasBiometry = hasBiometry, hasBiometry = hasBiometry,
) )
return hotWalletPasswordRequester.requestPassword(attemptRequest).toAuth() val auth = hotWalletPasswordRequester.requestPassword(attemptRequest).toAuth()
?: throw TangemSdkError.UserCancelled() ?: throw TangemSdkError.UserCancelled()
return RequestedAuth(auth, attemptRequest)
} }
private suspend fun isAccessCodeRequired(): Boolean { private suspend fun isAccessCodeRequired(): Boolean {

View file

@ -2,6 +2,7 @@ package com.tangem.domain.wallets.hot
import com.tangem.hot.sdk.model.HotAuth import com.tangem.hot.sdk.model.HotAuth
import com.tangem.hot.sdk.model.HotWalletId import com.tangem.hot.sdk.model.HotWalletId
import java.util.UUID
/** /**
* Interface for requesting the password for a hot wallet. * Interface for requesting the password for a hot wallet.
@ -11,13 +12,19 @@ interface HotWalletPasswordRequester {
/** /**
* Sets state to show wrong password state. * Sets state to show wrong password state.
*
* The result is attributed to [attemptRequest] (the request that produced it), not to whatever
* request currently owns the dialog. This prevents a late callback of one request from
* incrementing the failed-attempt counter of a different wallet that meanwhile replaced it.
*/ */
suspend fun wrongPassword() suspend fun wrongPassword(attemptRequest: AttemptRequest)
/** /**
* Sets state to show successful authentication state. * Sets state to show successful authentication state.
*
* Attributed to [attemptRequest], see [wrongPassword].
*/ */
suspend fun successfulAuthentication() suspend fun successfulAuthentication(attemptRequest: AttemptRequest)
/** /**
* Requests the user to enter the password for the hot wallet. * Requests the user to enter the password for the hot wallet.
@ -38,11 +45,14 @@ interface HotWalletPasswordRequester {
* In auth mode user can be deleted after failed attempts. * In auth mode user can be deleted after failed attempts.
* @param hasBiometry Indicates whether to show biometric authentication option to the user. * @param hasBiometry Indicates whether to show biometric authentication option to the user.
* Will be ignored if the device does not support biometry at the moment of the request. * Will be ignored if the device does not support biometry at the moment of the request.
* @param requestId Unique identity of this request, used to bind async result callbacks
* (wrong/successful) back to the exact request that produced them.
*/ */
data class AttemptRequest( data class AttemptRequest(
val hotWalletId: HotWalletId, val hotWalletId: HotWalletId,
val authMode: Boolean, val authMode: Boolean,
val hasBiometry: Boolean, val hasBiometry: Boolean,
val requestId: String = UUID.randomUUID().toString(),
) )
sealed class Result { sealed class Result {

View file

@ -21,12 +21,12 @@ internal class DefaultHotAccessCodeRequestComponent @AssistedInject constructor(
private val model: HotAccessCodeRequestModel = getOrCreateModel(params) private val model: HotAccessCodeRequestModel = getOrCreateModel(params)
override suspend fun wrongPassword() { override suspend fun wrongPassword(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
model.wrongAccessCode() model.wrongAccessCode(attemptRequest)
} }
override suspend fun successfulAuthentication() { override suspend fun successfulAuthentication(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
model.successfulAuthentication() model.successfulAuthentication(attemptRequest)
} }
override suspend fun requestPassword( override suspend fun requestPassword(

View file

@ -67,7 +67,7 @@ internal class HotAccessCodeRequestModel @Inject constructor(
currentRequest.value = attemptRequest currentRequest.value = attemptRequest
result.value = null // Reset the result when showing the dialog result.value = null // Reset the result when showing the dialog
subscribeToAttempts(id = attemptRequest.attemptId) subscribeToAttempts(attemptRequest)
uiState.update { uiState.update {
it.copy( it.copy(
isShown = true, isShown = true,
@ -88,31 +88,37 @@ internal class HotAccessCodeRequestModel @Inject constructor(
dismissState() dismissState()
} }
suspend fun wrongAccessCode() { suspend fun wrongAccessCode(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
val currentRequest = currentRequest.value ?: return hotAccessCodeAttemptsRepository.incrementAttempts(attemptRequest.attemptId)
hotAccessCodeAttemptsRepository.incrementAttempts(currentRequest.attemptId) // Reflect the wrong-code UI only if this request still owns the visible dialog.
uiState.update { if (isCurrentRequest(attemptRequest)) {
it.copy( uiState.update { state ->
state.copy(
accessCodeColor = PinTextColor.WrongCode, accessCodeColor = PinTextColor.WrongCode,
onAccessCodeChange = {}, onAccessCodeChange = {},
useBiometricVisible = currentRequest.isBiometryButtonVisible(), useBiometricVisible = attemptRequest.isBiometryButtonVisible(),
) )
} }
}
delay(timeMillis = 500) // Delay to show the wrong access code state delay(timeMillis = 500) // Delay to show the wrong access code state
} }
suspend fun successfulAuthentication() { suspend fun successfulAuthentication(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
val currentRequest = currentRequest.value ?: return hotAccessCodeAttemptsRepository.resetAttempts(attemptRequest.hotWalletId)
hotAccessCodeAttemptsRepository.resetAttempts(currentRequest.hotWalletId) if (isCurrentRequest(attemptRequest)) {
uiState.update { uiState.update {
it.copy( it.copy(
accessCodeColor = PinTextColor.Success, accessCodeColor = PinTextColor.Success,
onAccessCodeChange = {}, onAccessCodeChange = {},
) )
} }
}
delay(timeMillis = 200) // Delay to show the success state delay(timeMillis = 200) // Delay to show the success state
} }
private fun isCurrentRequest(attemptRequest: HotWalletPasswordRequester.AttemptRequest): Boolean =
currentRequest.value?.requestId == attemptRequest.requestId
private fun getInitialState() = HotAccessCodeRequestUM( private fun getInitialState() = HotAccessCodeRequestUM(
onDismiss = ::dismiss, onDismiss = ::dismiss,
onAccessCodeChange = ::onAccessCodeChange, onAccessCodeChange = ::onAccessCodeChange,
@ -146,7 +152,8 @@ internal class HotAccessCodeRequestModel @Inject constructor(
} }
} }
private fun subscribeToAttempts(id: HotWalletAccessCodeAttemptsRepository.AttemptId) { private fun subscribeToAttempts(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
val id = attemptRequest.attemptId
fun remainingSecondsToText(remainingSeconds: Int): TextReference? { fun remainingSecondsToText(remainingSeconds: Int): TextReference? {
return if (remainingSeconds > 0) { return if (remainingSeconds > 0) {
resourceReference( resourceReference(
@ -202,7 +209,7 @@ internal class HotAccessCodeRequestModel @Inject constructor(
) )
} }
} }
Attempts.Deletion -> deleteUserWallet() Attempts.Deletion -> deleteUserWallet(attemptRequest)
} }
} }
@ -217,8 +224,10 @@ internal class HotAccessCodeRequestModel @Inject constructor(
.any { it is UserWallet.Hot && it.hotWalletId == id } .any { it is UserWallet.Hot && it.hotWalletId == id }
} }
private suspend fun deleteUserWallet() { private suspend fun deleteUserWallet(expectedRequest: HotWalletPasswordRequester.AttemptRequest) {
val currentRequest = currentRequest.value ?: return val currentRequest = currentRequest.value ?: return
// Only delete if the request whose threshold was crossed is still the one owning the dialog.
if (currentRequest.requestId != expectedRequest.requestId) return
val userWallet = userWalletsListRepository.userWalletsSync() val userWallet = userWalletsListRepository.userWalletsSync()
.firstOrNull { it is UserWallet.Hot && it.hotWalletId == currentRequest.hotWalletId } ?: return .firstOrNull { it is UserWallet.Hot && it.hotWalletId == currentRequest.hotWalletId } ?: return

View file

@ -13,9 +13,11 @@ class HotWalletPasswordRequesterProxy @Inject constructor() : HotWalletPasswordR
val componentRequester = MutableStateFlow<HotWalletPasswordRequester?>(null) val componentRequester = MutableStateFlow<HotWalletPasswordRequester?>(null)
override suspend fun wrongPassword() = call { wrongPassword() } override suspend fun wrongPassword(attemptRequest: HotWalletPasswordRequester.AttemptRequest) =
call { wrongPassword(attemptRequest) }
override suspend fun successfulAuthentication() = call { successfulAuthentication() } override suspend fun successfulAuthentication(attemptRequest: HotWalletPasswordRequester.AttemptRequest) =
call { successfulAuthentication(attemptRequest) }
override suspend fun requestPassword( override suspend fun requestPassword(
attemptRequest: HotWalletPasswordRequester.AttemptRequest, attemptRequest: HotWalletPasswordRequester.AttemptRequest,

View file

@ -0,0 +1,169 @@
package com.tangem.features.hotwallet.accesscoderequest
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.settings.CanUseBiometryUseCase
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.AttemptId
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester.AttemptRequest
import com.tangem.domain.wallets.usecase.DeleteWalletUseCase
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class HotAccessCodeRequestModelTest {
private val hotAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository = mockk(relaxed = true)
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val deleteWalletUseCase: DeleteWalletUseCase = mockk(relaxed = true)
private val canUseBiometryUseCase: CanUseBiometryUseCase = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase = mockk(relaxed = true)
private val hotWalletFeatureToggles: HotWalletFeatureToggles = mockk {
every { isAssetsDiscoveryEnabled } returns false
}
private val hotWalletIdA: HotWalletId = mockk()
private val hotWalletIdB: HotWalletId = mockk()
private val walletIdA = UserWalletId("A")
private val walletIdB = UserWalletId("B")
private val userWalletA: UserWallet.Hot = mockk {
every { hotWalletId } returns hotWalletIdA
every { walletId } returns walletIdA
}
private val userWalletB: UserWallet.Hot = mockk {
every { hotWalletId } returns hotWalletIdB
every { walletId } returns walletIdB
}
private val requestA = AttemptRequest(hotWalletId = hotWalletIdA, authMode = true, hasBiometry = false)
private val requestB = AttemptRequest(hotWalletId = hotWalletIdB, authMode = true, hasBiometry = false)
private val attemptIdA = AttemptId(hotWalletId = hotWalletIdA, auth = true)
private val attemptIdB = AttemptId(hotWalletId = hotWalletIdB, auth = true)
@BeforeEach
fun setUp() {
clearMocks(hotAccessCodeAttemptsRepository, deleteWalletUseCase, answers = false)
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWalletA, userWalletB)
every { hotAccessCodeAttemptsRepository.getAttempts(any()) } returns emptyFlow()
}
@Test
fun `GIVEN request A replaced by B WHEN late wrong code of A THEN attempt attributed to A not B`() = runTest {
// Arrange
val model = createModel(this)
model.show(requestA)
advanceUntilIdle()
model.show(requestB) // B now owns the dialog
advanceUntilIdle()
// Act
model.wrongAccessCode(requestA) // late callback belonging to A
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { hotAccessCodeAttemptsRepository.incrementAttempts(attemptIdA) }
coVerify(exactly = 0) { hotAccessCodeAttemptsRepository.incrementAttempts(attemptIdB) }
model.onDestroy()
}
@Test
fun `GIVEN request A replaced by B WHEN late success of A THEN reset attributed to A not B`() = runTest {
// Arrange
val model = createModel(this)
model.show(requestA)
advanceUntilIdle()
model.show(requestB)
advanceUntilIdle()
// Act
model.successfulAuthentication(requestA) // late callback belonging to A
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { hotAccessCodeAttemptsRepository.resetAttempts(hotWalletIdA) }
coVerify(exactly = 0) { hotAccessCodeAttemptsRepository.resetAttempts(hotWalletIdB) }
model.onDestroy()
}
@Test
fun `GIVEN request A owns the dialog WHEN wrong code of A THEN attempt incremented for A`() = runTest {
// Arrange
val model = createModel(this)
model.show(requestA)
advanceUntilIdle()
// Act
model.wrongAccessCode(requestA)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { hotAccessCodeAttemptsRepository.incrementAttempts(attemptIdA) }
model.onDestroy()
}
@Test
fun `GIVEN B reaches deletion threshold WHEN B owns the dialog THEN only B is deleted`() = runTest {
// Arrange
every { hotAccessCodeAttemptsRepository.getAttempts(attemptIdB) } returns flowOf(Attempts.Deletion)
val model = createModel(this)
// Act
model.show(requestB)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { deleteWalletUseCase(walletIdB) }
coVerify(exactly = 0) { deleteWalletUseCase(walletIdA) }
model.onDestroy()
}
private fun createModel(testScope: TestScope): HotAccessCodeRequestModel {
return HotAccessCodeRequestModel(
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
hotAccessCodeAttemptsRepository = hotAccessCodeAttemptsRepository,
userWalletsListRepository = userWalletsListRepository,
deleteWalletUseCase = deleteWalletUseCase,
canUseBiometryUseCase = canUseBiometryUseCase,
analyticsEventHandler = analyticsEventHandler,
startAssetsDiscoveryUseCase = startAssetsDiscoveryUseCase,
hotWalletFeatureToggles = hotWalletFeatureToggles,
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}