Updated on 2026-08-14

This commit is contained in:
Tangem 2025-05-20 10:27:00 +03:00
parent 36aaeca687
commit f27a28f0b7
18 changed files with 357 additions and 303 deletions

View file

@ -2,7 +2,6 @@ package com.tangem.tap.domain.tasks.visa
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.common.UnmarshalHelper
import com.tangem.common.CompletionResult
@ -21,7 +20,6 @@ import com.tangem.datasource.local.visa.hasSavedOTP
import com.tangem.domain.common.visa.VisaWalletPublicKeyUtility
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.visa.error.VisaActivationError
import com.tangem.domain.visa.error.VisaAuthorizationAPIError
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
@ -170,28 +168,24 @@ class VisaCardActivationTask @AssistedInject constructor(
signedChallenge: VisaAuthSignedChallenge,
cardWalletAddress: String,
): Either<TangemError, VisaDataToSignByCardWallet> = either {
catch(
block = {
val tokens = visaAuthRepository.getAccessTokens(signedChallenge)
val tokens = visaAuthRepository.getAccessTokens(signedChallenge)
.getOrElse { raise(it.tangemError) }
visaAuthTokenStorage.store(cardId, tokens)
visaAuthTokenStorage.store(cardId, tokens)
val remoteState = visaActivationRepository.getActivationRemoteState()
if (remoteState !is VisaActivationRemoteState.CardWalletSignatureRequired) {
raise(VisaActivationError.WrongRemoteState.tangemError)
}
val remoteState = visaActivationRepository.getActivationRemoteState()
.getOrElse { raise(it.tangemError) }
visaActivationRepository.getCardWalletAcceptanceData(
VisaCardWalletDataToSignRequest(
activationOrderInfo = remoteState.activationOrderInfo,
cardWalletAddress = cardWalletAddress,
),
)
},
catch = {
raise(VisaAuthorizationAPIError.tangemError)
},
)
if (remoteState !is VisaActivationRemoteState.CardWalletSignatureRequired) {
return raise(VisaActivationError.WrongRemoteState.tangemError)
}
visaActivationRepository.getCardWalletAcceptanceData(
VisaCardWalletDataToSignRequest(
activationOrderInfo = remoteState.activationOrderInfo,
cardWalletAddress = cardWalletAddress,
),
).getOrElse { raise(it.tangemError) }
}
private suspend fun SessionContext.createWallet(): CompletionResult<Unit> {

View file

@ -11,7 +11,7 @@ import com.tangem.core.error.ext.tangemError
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.domain.common.visa.VisaWalletPublicKeyUtility
import com.tangem.domain.visa.error.VisaActivationError
import com.tangem.domain.visa.error.VisaAuthorizationAPIError
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.error.VisaCardScanError
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaActivationRepository
@ -82,16 +82,12 @@ internal class VisaCardScanHandler @Inject constructor(
}
Timber.i("Requesting challenge for wallet authorization")
val challengeResponse = runCatching {
// TODO [REDACTED_TASK_KEY]
error("sign and get specific error to switch to card_id flow")
visaAuthRepository.getCardWalletAuthChallenge(cardWalletAddress = walletAddress.value)
}.getOrElse {
Timber.i("Failed to get Access token for Wallet public key authorization. Authorizing using Card Pub key")
return handleCardAuthorization(
cardWalletAddress = walletAddress.value,
)
}
val challengeResponse = visaAuthRepository.getCardWalletAuthChallenge(cardWalletAddress = walletAddress.value)
.getOrElse {
Timber.i("Failed to get Access token for Wallet public key authorization")
return CompletionResult.Failure(it.tangemError)
}
val signChallengeResult = signChallengeWithWallet(
publicKey = wallet.publicKey,
@ -118,14 +114,19 @@ internal class VisaCardScanHandler @Inject constructor(
cardWalletAddress: String,
signedChallenge: VisaAuthSignedChallenge,
): CompletionResult<VisaCardActivationStatus> {
val authorizationTokensResponse = runCatching {
visaAuthRepository.getAccessTokens(signedChallenge = signedChallenge)
}.getOrElse {
Timber.i("Failed to get Access token for Wallet public key authorization. Authorizing using Card Pub key")
return handleCardAuthorization(
cardWalletAddress = cardWalletAddress,
)
}
val authorizationTokensResponse = visaAuthRepository.getAccessTokens(signedChallenge = signedChallenge)
.getOrElse {
Timber.i("Failed to get Access token for Wallet public key authorization.")
return if (
it is VisaApiError.ProductInstanceIsNotActivated ||
it is VisaApiError.ProductInstanceNotFoundActivationRequired
) {
Timber.i("Proceeding with card authorization.")
handleCardAuthorization(cardWalletAddress = cardWalletAddress)
} else {
CompletionResult.Failure(it.tangemError)
}
}
Timber.i("Authorized using Wallet public key successfully")
@ -140,14 +141,12 @@ internal class VisaCardScanHandler @Inject constructor(
Timber.i("Requesting authorization challenge to sign")
val challengeResponse = runCatching {
visaAuthRepository.getCardAuthChallenge(
cardId = card.cardId,
cardPublicKey = card.cardPublicKey.toHexString(),
)
}.getOrElse {
Timber.e("Failed to get challenge for Card authorization. Plain error: ${it.message}")
return CompletionResult.Failure(VisaAuthorizationAPIError.tangemError)
val challengeResponse = visaAuthRepository.getCardAuthChallenge(
cardId = card.cardId,
cardPublicKey = card.cardPublicKey.toHexString(),
).getOrElse {
Timber.e("Failed to get challenge for Card authorization. Plain error: ${it.errorCode}")
return CompletionResult.Failure(it.tangemError)
}
Timber.i("Received challenge to sign: ${challengeResponse.challenge}")
@ -168,16 +167,14 @@ internal class VisaCardScanHandler @Inject constructor(
}
@Suppress("UnusedPrivateMember")
val authorizationTokensResponse = runCatching {
visaAuthRepository.getAccessTokens(
signedChallenge = challengeResponse.toSignedChallenge(
signedChallenge = attestCardKeyResponse.cardSignature.toHexString(),
salt = attestCardKeyResponse.salt.toHexString(),
),
)
}.getOrElse {
Timber.e("Failed to sign challenge with Card public key. Plain error: ${it.message}")
return CompletionResult.Failure(VisaAuthorizationAPIError.tangemError)
val authorizationTokensResponse = visaAuthRepository.getAccessTokens(
signedChallenge = challengeResponse.toSignedChallenge(
signedChallenge = attestCardKeyResponse.cardSignature.toHexString(),
salt = attestCardKeyResponse.salt.toHexString(),
),
).getOrElse {
Timber.e("Failed to sign challenge with Card public key. Plain error: ${it.errorCode}")
return CompletionResult.Failure(it.tangemError)
}
visaAuthTokenStorage.store(
@ -185,11 +182,9 @@ internal class VisaCardScanHandler @Inject constructor(
tokens = authorizationTokensResponse,
)
val activationRemoteState = runCatching {
visaActivationRepository.getActivationRemoteState()
}.getOrElse {
Timber.e("Failed to sign challenge with Card public key. Plain error: ${it.message}")
return CompletionResult.Failure(VisaAuthorizationAPIError.tangemError)
val activationRemoteState = visaActivationRepository.getActivationRemoteState().getOrElse {
Timber.e("Failed to sign challenge with Card public key. Plain error: ${it.errorCode}")
return CompletionResult.Failure(it.tangemError)
}
val error = when (activationRemoteState) {

View file

@ -25,16 +25,18 @@ interface TangemVisaApi {
// region: auth
@POST("v1/auth/challenge")
suspend fun generateNonceByCardId(@Body request: GenerateNoneByCardIdRequest): GenerateNonceResponse
suspend fun generateNonceByCardId(@Body request: GenerateNoneByCardIdRequest): ApiResponse<GenerateNonceResponse>
@POST("v1/auth/challenge")
suspend fun generateNonceByCardWallet(@Body request: GenerateNoneByCardWalletRequest): GenerateNonceResponse
suspend fun generateNonceByCardWallet(
@Body request: GenerateNoneByCardWalletRequest,
): ApiResponse<GenerateNonceResponse>
@POST("v1/auth/token")
suspend fun getAccessTokenByCardId(@Body request: GetAccessTokenByCardIdRequest): JWTResponse
suspend fun getAccessTokenByCardId(@Body request: GetAccessTokenByCardIdRequest): ApiResponse<JWTResponse>
@POST("v1/auth/token")
suspend fun getAccessTokenByCardWallet(@Body request: GetAccessTokenByCardWalletRequest): JWTResponse
suspend fun getAccessTokenByCardWallet(@Body request: GetAccessTokenByCardWalletRequest): ApiResponse<JWTResponse>
@POST("v1/auth/token/refresh")
suspend fun refreshCardIdAccessToken(@Body request: RefreshTokenByCardIdRequest): ApiResponse<JWTResponse>

View file

@ -0,0 +1,14 @@
package com.tangem.datasource.api.visa.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class VisaErrorResponse(
@Json(name = "error") val error: Error,
) {
@JsonClass(generateAdapter = true)
data class Error(
@Json(name = "code") val code: Int,
)
}

View file

@ -1,5 +1,8 @@
package com.tangem.data.visa
import arrow.core.Either
import arrow.core.getOrElse
import com.squareup.moshi.Moshi
import com.tangem.data.visa.config.VisaLibLoader
import com.tangem.data.visa.converter.VisaActivationStatusConverterWithState
import com.tangem.datasource.api.common.config.ApiConfig
@ -8,14 +11,11 @@ import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.visa.TangemVisaApi
import com.tangem.datasource.api.visa.models.request.ActivationByCardWalletRequest
import com.tangem.datasource.api.visa.models.request.ActivationByCustomerWalletRequest
import com.tangem.datasource.api.visa.models.request.ActivationStatusRequest
import com.tangem.datasource.api.visa.models.request.GetCardWalletAcceptanceRequest
import com.tangem.datasource.api.visa.models.request.GetCustomerWalletAcceptanceRequest
import com.tangem.datasource.api.visa.models.request.SetPinCodeRequest
import com.tangem.datasource.api.visa.models.request.*
import com.tangem.datasource.api.visa.models.response.VisaErrorResponseJsonAdapter
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.domain.visa.exception.RefreshTokenExpiredException
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
@ -28,6 +28,7 @@ import kotlinx.coroutines.withContext
@Suppress("LongParameterList")
internal class DefaultVisaActivationRepository @AssistedInject constructor(
@Assisted private val visaCardId: VisaCardId,
@NetworkMoshi private val moshi: Moshi,
private val visaApi: TangemVisaApi,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val visaAuthTokenStorage: VisaAuthTokenStorage,
@ -36,27 +37,30 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
private val apiConfigsManager: ApiConfigsManager,
) : VisaActivationRepository {
override suspend fun getActivationRemoteState(): VisaActivationRemoteState = withContext(dispatcherProvider.io) {
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi)
visaApi.getRemoteActivationStatus(
authHeader = authTokens.getAuthHeader(),
request = ActivationStatusRequest(
cardId = visaCardId.cardId,
cardPublicKey = visaCardId.cardPublicKey,
),
).getOrThrow()
override suspend fun getActivationRemoteState(): Either<VisaApiError, VisaActivationRemoteState> =
withContext(dispatcherProvider.io) {
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
visaApi.getRemoteActivationStatus(
authHeader = authTokens.getAuthHeader(),
request = ActivationStatusRequest(
cardId = visaCardId.cardId,
cardPublicKey = visaCardId.cardPublicKey,
),
).getOrThrow()
}.map {
VisaActivationStatusConverterWithState.convert(it)
}
}
VisaActivationStatusConverterWithState.convert(result)
}
override suspend fun getCardWalletAcceptanceData(
request: VisaCardWalletDataToSignRequest,
): VisaDataToSignByCardWallet = withContext(dispatcherProvider.io) {
val result = request {
): Either<VisaApiError, VisaDataToSignByCardWallet> = withContext(dispatcherProvider.io) {
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
@ -67,18 +71,18 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
cardWalletAddress = request.cardWalletAddress,
),
).getOrThrow()
}.map {
VisaDataToSignByCardWallet(
request = request,
hashToSign = it.result.hash,
)
}
VisaDataToSignByCardWallet(
request = request,
hashToSign = result.result.hash,
)
}
override suspend fun getCustomerWalletAcceptanceData(
request: VisaCustomerWalletDataToSignRequest,
): VisaDataToSignByCustomerWallet = withContext(dispatcherProvider.io) {
val result = request {
): Either<VisaApiError, VisaDataToSignByCustomerWallet> = withContext(dispatcherProvider.io) {
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
@ -89,15 +93,15 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
customerWalletAddress = request.customerWalletAddress,
),
).getOrThrow()
}.map {
VisaDataToSignByCustomerWallet(
request = request,
hashToSign = it.result.hash,
)
}
VisaDataToSignByCustomerWallet(
request = request,
hashToSign = result.result.hash,
)
}
override suspend fun activateCard(signedData: VisaSignedActivationDataByCardWallet) {
override suspend fun activateCard(signedData: VisaSignedActivationDataByCardWallet): Either<VisaApiError, Unit> =
withContext(dispatcherProvider.io) {
request {
val authTokens =
@ -120,9 +124,8 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
).getOrThrow()
}
}
}
override suspend fun approveByCustomerWallet(signedData: VisaSignedDataByCustomerWallet) {
override suspend fun approveByCustomerWallet(signedData: VisaSignedDataByCustomerWallet) =
withContext(dispatcherProvider.io) {
request {
val authTokens =
@ -140,24 +143,21 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
).getOrThrow()
}
}
}
override suspend fun sendPinCode(pinCode: VisaEncryptedPinCode) {
withContext(dispatcherProvider.io) {
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
override suspend fun sendPinCode(pinCode: VisaEncryptedPinCode) = withContext(dispatcherProvider.io) {
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
visaApi.setPinCode(
authHeader = authTokens.getAuthHeader(),
body = SetPinCodeRequest(
orderId = pinCode.activationOrderId,
sessionId = pinCode.sessionId,
iv = pinCode.iv,
pin = pinCode.encryptedPin,
),
).getOrThrow()
}
visaApi.setPinCode(
authHeader = authTokens.getAuthHeader(),
body = SetPinCodeRequest(
orderId = pinCode.activationOrderId,
sessionId = pinCode.sessionId,
iv = pinCode.iv,
pin = pinCode.encryptedPin,
),
).getOrThrow()
}
}
@ -172,24 +172,42 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
}
}
private suspend fun <T : Any> request(requestBlock: suspend () -> T): T {
private suspend fun <T : Any> request(requestBlock: suspend () -> T): Either<VisaApiError, T> {
return runCatching {
requestBlock()
Either.Right(requestBlock())
}.getOrElse { responseError ->
if (responseError !is ApiResponseError.HttpException ||
responseError.code != ApiResponseError.HttpException.Code.UNAUTHORIZED
if (responseError is ApiResponseError.HttpException &&
responseError.code != ApiResponseError.HttpException.Code.UNAUTHORIZED &&
responseError.errorBody != null
) {
throw responseError
val errorCode =
visaErrorAdapter.fromJson(responseError.errorBody!!)?.error?.code ?: responseError.code.numericCode
return Either.Left(VisaApiError.fromBackendError(errorCode))
}
if (responseError !is ApiResponseError.HttpException) {
return Either.Left(VisaApiError.UnknownWithoutCode)
}
val authTokens = visaAuthTokenStorage.get(visaCardId.cardId) ?: error("Auth tokens are not stored")
val newTokens = runCatching {
visaAuthRepository.refreshAccessTokens(authTokens.refreshToken)
}.getOrElse { throw RefreshTokenExpiredException() }
val newTokens = visaAuthRepository.refreshAccessTokens(authTokens.refreshToken).getOrElse {
return Either.Left(VisaApiError.RefreshTokenExpired)
}
visaAuthTokenStorage.store(visaCardId.cardId, newTokens)
requestBlock()
runCatching {
Either.Right(requestBlock())
}.getOrElse { responseError ->
if (responseError is ApiResponseError.HttpException && responseError.errorBody != null) {
val errorCode =
visaErrorAdapter.fromJson(responseError.errorBody!!)?.error?.code
?: responseError.code.numericCode
Either.Left(VisaApiError.fromBackendError(errorCode))
} else {
Either.Left(VisaApiError.UnknownWithoutCode)
}
}
}
}

View file

@ -1,8 +1,14 @@
package com.tangem.data.visa
import arrow.core.Either
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.visa.TangemVisaApi
import com.tangem.datasource.api.visa.models.request.*
import com.tangem.datasource.api.visa.models.response.VisaErrorResponseJsonAdapter
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaAuthSession
import com.tangem.domain.visa.model.VisaAuthSignedChallenge
@ -14,40 +20,54 @@ import javax.inject.Inject
@Suppress("UnusedPrivateMember")
internal class DefaultVisaAuthRepository @Inject constructor(
@NetworkMoshi private val moshi: Moshi,
private val visaAuthApi: TangemVisaApi,
private val dispatchers: CoroutineDispatcherProvider,
) : VisaAuthRepository {
override suspend fun getCardAuthChallenge(cardId: String, cardPublicKey: String): VisaAuthChallenge.Card =
withContext(dispatchers.io) {
val response = visaAuthApi.generateNonceByCardId(
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi)
override suspend fun getCardAuthChallenge(
cardId: String,
cardPublicKey: String,
): Either<VisaApiError, VisaAuthChallenge.Card> = withContext(dispatchers.io) {
request {
visaAuthApi.generateNonceByCardId(
GenerateNoneByCardIdRequest(
cardId = cardId,
cardPublicKey = cardPublicKey,
),
)
).getOrThrow()
}.map { response ->
VisaAuthChallenge.Card(
challenge = response.result.nonce,
session = VisaAuthSession(response.result.sessionId),
)
}
}
override suspend fun getCardWalletAuthChallenge(cardWalletAddress: String): VisaAuthChallenge.Wallet =
withContext(dispatchers.io) {
val response = visaAuthApi.generateNonceByCardWallet(
override suspend fun getCardWalletAuthChallenge(
cardWalletAddress: String,
): Either<VisaApiError, VisaAuthChallenge.Wallet> = withContext(dispatchers.io) {
request {
visaAuthApi.generateNonceByCardWallet(
GenerateNoneByCardWalletRequest(
cardWalletAddress = cardWalletAddress,
),
)
).getOrThrow()
}.map { response ->
VisaAuthChallenge.Wallet(
challenge = response.result.nonce,
session = VisaAuthSession(response.result.sessionId),
)
}
}
override suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): VisaAuthTokens =
withContext(dispatchers.io) {
val response = when (signedChallenge) {
override suspend fun getAccessTokens(
signedChallenge: VisaAuthSignedChallenge,
): Either<VisaApiError, VisaAuthTokens> = withContext(dispatchers.io) {
request {
when (signedChallenge) {
is VisaAuthSignedChallenge.ByCardPublicKey -> {
visaAuthApi.getAccessTokenByCardId(
GetAccessTokenByCardIdRequest(
@ -55,7 +75,7 @@ internal class DefaultVisaAuthRepository @Inject constructor(
signature = signedChallenge.signature,
salt = signedChallenge.salt,
),
)
).getOrThrow()
}
is VisaAuthSignedChallenge.ByWallet -> {
visaAuthApi.getAccessTokenByCardWallet(
@ -63,9 +83,10 @@ internal class DefaultVisaAuthRepository @Inject constructor(
sessionId = signedChallenge.challenge.session.sessionId,
signature = signedChallenge.signature,
),
)
).getOrThrow()
}
}
}.map { response ->
VisaAuthTokens(
accessToken = response.result.accessToken,
refreshToken = VisaAuthTokens.RefreshToken(
@ -77,10 +98,13 @@ internal class DefaultVisaAuthRepository @Inject constructor(
),
)
}
}
override suspend fun refreshAccessTokens(refreshToken: VisaAuthTokens.RefreshToken): VisaAuthTokens =
withContext(dispatchers.io) {
val response = when (refreshToken.authType) {
override suspend fun refreshAccessTokens(
refreshToken: VisaAuthTokens.RefreshToken,
): Either<VisaApiError, VisaAuthTokens> = withContext(dispatchers.io) {
request {
when (refreshToken.authType) {
VisaAuthTokens.RefreshToken.Type.CardId ->
visaAuthApi.refreshCardIdAccessToken(
RefreshTokenByCardIdRequest(refreshToken = refreshToken.value),
@ -90,10 +114,27 @@ internal class DefaultVisaAuthRepository @Inject constructor(
RefreshTokenByCardIdRequest(refreshToken = refreshToken.value),
)
}.getOrThrow()
}.map { response ->
VisaAuthTokens(
accessToken = response.result.accessToken,
refreshToken = refreshToken.copy(value = response.result.refreshToken),
)
}
}
private suspend fun <T : Any> request(requestBlock: suspend () -> T): Either<VisaApiError, T> {
return runCatching {
Either.Right(requestBlock())
}.getOrElse { responseError ->
if (responseError is ApiResponseError.HttpException &&
responseError.errorBody != null
) {
val errorCode =
visaErrorAdapter.fromJson(responseError.errorBody!!)?.error?.code ?: responseError.code.numericCode
return Either.Left(VisaApiError.fromBackendError(errorCode))
}
return Either.Left(VisaApiError.UnknownWithoutCode)
}
}
}

View file

@ -1,59 +0,0 @@
package com.tangem.data.visa
import com.tangem.common.extensions.toHexString
import com.tangem.crypto.CryptoUtils
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaActivationRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Suppress("UnusedPrivateMember")
class MockVisaActivationRepository @AssistedInject constructor(
@Assisted private val visaCardId: VisaCardId,
) : VisaActivationRepository {
override suspend fun getActivationRemoteState(): VisaActivationRemoteState {
return VisaActivationRemoteState.CardWalletSignatureRequired(
activationOrderInfo = VisaActivationOrderInfo(
orderId = "orderId",
customerId = "customerId",
customerWalletAddress = "customerWalletAddress",
cardWalletAddress = null,
),
)
}
override suspend fun getCardWalletAcceptanceData(
request: VisaCardWalletDataToSignRequest,
): VisaDataToSignByCardWallet {
return VisaDataToSignByCardWallet(
request = request,
hashToSign = CryptoUtils.generateRandomBytes(length = 32).toHexString(),
)
}
override suspend fun getCustomerWalletAcceptanceData(
request: VisaCustomerWalletDataToSignRequest,
): VisaDataToSignByCustomerWallet {
return VisaDataToSignByCustomerWallet(
request = request,
CryptoUtils.generateRandomBytes(length = 32).toHexString(),
)
}
override suspend fun activateCard(signedData: VisaSignedActivationDataByCardWallet) {}
override suspend fun approveByCustomerWallet(signedData: VisaSignedDataByCustomerWallet) {}
override suspend fun sendPinCode(pinCode: VisaEncryptedPinCode) {}
override suspend fun getPinCodeRsaEncryptionPublicKey(): String {
return CryptoUtils.generateRandomBytes(length = 32).toHexString()
}
@AssistedFactory
interface Factory : VisaActivationRepository.Factory {
override fun create(cardId: VisaCardId): MockVisaActivationRepository
}
}

View file

@ -15,6 +15,7 @@ dependencies {
/** Project - Domain */
implementation(projects.core.utils)
implementation(projects.core.error)
implementation(projects.domain.core)
implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens.models)

View file

@ -11,7 +11,6 @@ import com.tangem.core.error.UniversalError
* zzz - Specific error code
* If you need to add new subsystem add it to list below incrementing last code.
* `Subsystems`:
* `001` - Common API
* `002` - First card scan error
* `003` - Activation
* `004` - Authorization API
@ -20,10 +19,6 @@ object VisaError : UniversalError {
override val errorCode: Int = 104000000
}
object VisaAPIError : UniversalError {
override val errorCode: Int = 104001000
}
enum class VisaCardScanError(
override val errorCode: Int,
) : UniversalError {
@ -47,8 +42,36 @@ enum class VisaActivationError(
FailedRemoteState(104003010),
VisaCardForApproval(104003011),
CardIdNotMatched(104003011),
FailedToSetPinCode(104003012),
}
object VisaAuthorizationAPIError : UniversalError {
override val errorCode: Int = 104004000
sealed class VisaApiError(
override val errorCode: Int,
) : UniversalError {
// TODO codes may change
data object Unspecified : VisaApiError(104100000)
data object ProductInstanceNotFoundActivationRequired : VisaApiError(104100100)
data object ProductInstanceIsBlocked : VisaApiError(104101000)
data object ProductInstanceIsNotActivated : VisaApiError(104101100)
data object ProductInstanceIsAlreadyActivated : VisaApiError(104101200)
data object CustomerIsBlocked : VisaApiError(104102000)
data object UnknownWithoutCode : VisaApiError(104101999)
data class Unknown(override val errorCode: Int) : VisaApiError(errorCode)
data object RefreshTokenExpired : VisaApiError(104004001)
companion object {
fun fromBackendError(backendErrorCode: Int): VisaApiError {
val universalErrorCode = 104_000_000 + backendErrorCode
return when (universalErrorCode) {
104100000 -> Unspecified
104100100 -> ProductInstanceNotFoundActivationRequired
104101000 -> ProductInstanceIsBlocked
104101100 -> ProductInstanceIsNotActivated
104101200 -> ProductInstanceIsAlreadyActivated
104102000 -> CustomerIsBlocked
else -> Unknown(universalErrorCode)
}
}
}
}

View file

@ -4,6 +4,8 @@ package com.tangem.domain.visa
import android.util.Base64
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.visa.error.VisaActivationError
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.visa.model.VisaEncryptedPinCode
import com.tangem.domain.visa.repository.VisaActivationRepository
@ -25,7 +27,7 @@ class SetVisaPinCodeUseCase(private val visaActivationRepositoryFactory: VisaAct
visaCardId: VisaCardId,
activationOrderId: String,
pinCode: String,
): Either<Throwable, Unit> = Either.catch {
): Either<UniversalError, Unit> = runCatching {
val visaActivationRepository = visaActivationRepositoryFactory.create(visaCardId)
val rsaPublicKey = visaActivationRepository.getPinCodeRsaEncryptionPublicKey()
val formattedPin = "24$pinCode${"f".repeat(n = 8)}FF"
@ -48,7 +50,7 @@ class SetVisaPinCodeUseCase(private val visaActivationRepositoryFactory: VisaAct
encryptedPin = encryptedPin,
),
)
}
}.getOrElse { Either.Left(VisaActivationError.FailedToSetPinCode) }
private fun generateSessionKey(): Key {
val generator = KeyGenerator.getInstance("AES")

View file

@ -1,22 +1,26 @@
package com.tangem.domain.visa.repository
import arrow.core.Either
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.*
interface VisaActivationRepository {
suspend fun getActivationRemoteState(): VisaActivationRemoteState
suspend fun getActivationRemoteState(): Either<VisaApiError, VisaActivationRemoteState>
suspend fun getCardWalletAcceptanceData(request: VisaCardWalletDataToSignRequest): VisaDataToSignByCardWallet
suspend fun getCardWalletAcceptanceData(
request: VisaCardWalletDataToSignRequest,
): Either<VisaApiError, VisaDataToSignByCardWallet>
suspend fun getCustomerWalletAcceptanceData(
request: VisaCustomerWalletDataToSignRequest,
): VisaDataToSignByCustomerWallet
): Either<VisaApiError, VisaDataToSignByCustomerWallet>
suspend fun activateCard(signedData: VisaSignedActivationDataByCardWallet)
suspend fun activateCard(signedData: VisaSignedActivationDataByCardWallet): Either<VisaApiError, Unit>
suspend fun approveByCustomerWallet(signedData: VisaSignedDataByCustomerWallet)
suspend fun approveByCustomerWallet(signedData: VisaSignedDataByCustomerWallet): Either<VisaApiError, Unit>
suspend fun sendPinCode(pinCode: VisaEncryptedPinCode)
suspend fun sendPinCode(pinCode: VisaEncryptedPinCode): Either<VisaApiError, Unit>
suspend fun getPinCodeRsaEncryptionPublicKey(): String

View file

@ -1,16 +1,21 @@
package com.tangem.domain.visa.repository
import arrow.core.Either
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaAuthSignedChallenge
import com.tangem.domain.visa.model.VisaAuthTokens
interface VisaAuthRepository {
suspend fun getCardAuthChallenge(cardId: String, cardPublicKey: String): VisaAuthChallenge.Card
suspend fun getCardAuthChallenge(
cardId: String,
cardPublicKey: String,
): Either<VisaApiError, VisaAuthChallenge.Card>
suspend fun getCardWalletAuthChallenge(cardWalletAddress: String): VisaAuthChallenge.Wallet
suspend fun getCardWalletAuthChallenge(cardWalletAddress: String): Either<VisaApiError, VisaAuthChallenge.Wallet>
suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): VisaAuthTokens
suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): Either<VisaApiError, VisaAuthTokens>
suspend fun refreshAccessTokens(refreshToken: VisaAuthTokens.RefreshToken): VisaAuthTokens
suspend fun refreshAccessTokens(refreshToken: VisaAuthTokens.RefreshToken): Either<VisaApiError, VisaAuthTokens>
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.accesscode.model
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.input.TextFieldValue
import arrow.core.getOrElse
import com.tangem.common.CompletionResult
import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -9,10 +10,9 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.error.UniversalError
import com.tangem.core.error.ext.universalError
import com.tangem.core.ui.utils.showErrorDialog
import com.tangem.domain.visa.error.VisaAPIError
import com.tangem.domain.visa.error.VisaAuthorizationAPIError
import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest
@ -153,14 +153,11 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
loading(true)
modelScope.launch {
val challengeToSign = runCatching {
visaAuthRepository.getCardAuthChallenge(
cardId = activationInput.cardId,
cardPublicKey = activationInput.cardPublicKey,
)
}.getOrElse {
loading(false)
uiMessageSender.showErrorDialog(VisaAuthorizationAPIError)
val challengeToSign = visaAuthRepository.getCardAuthChallenge(
cardId = activationInput.cardId,
cardPublicKey = activationInput.cardPublicKey,
).getOrElse {
onError(it)
return@launch
}
@ -174,23 +171,17 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
val resultData = when (result) {
is CompletionResult.Failure -> {
loading(false)
uiMessageSender.showErrorDialog(result.error.universalError)
analyticsEventsHandler.send(
VisaAnalyticsEvent.ErrorOnboarding(result.error.universalError),
)
onError(result.error.universalError)
return@launch
}
is CompletionResult.Success -> result.data
}
runCatching {
visaActivationRepository.activateCard(resultData.signedActivationData)
}.onFailure {
loading(false)
uiMessageSender.showErrorDialog(VisaAPIError)
return@launch
}
visaActivationRepository.activateCard(resultData.signedActivationData)
.onLeft {
onError(it)
return@launch
}
val targetAddress =
result.data.signedActivationData.dataToSign.request.activationOrderInfo.customerWalletAddress
@ -213,6 +204,12 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
}
}
private fun onError(error: UniversalError) {
loading(false)
uiMessageSender.showErrorDialog(error)
analyticsEventsHandler.send(VisaAnalyticsEvent.ErrorOnboarding(error))
}
private fun loading(state: Boolean) {
_uiState.update { it.copy(buttonLoading = state) }
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.onboarding.v2.visa.impl.child.approve.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.CompletionResult
import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -8,9 +9,9 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.error.UniversalError
import com.tangem.core.error.ext.universalError
import com.tangem.core.ui.utils.showErrorDialog
import com.tangem.domain.visa.error.VisaAPIError
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.repository.VisaActivationRepository
@ -67,13 +68,12 @@ internal class OnboardingVisaApproveModel @Inject constructor(
analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ButtonApprove)
modelScope.launch {
val dataToSign = runCatching {
val dataToSign =
visaActivationRepository.getCustomerWalletAcceptanceData(params.preparationDataForApprove.request)
}.getOrElse {
loading(false)
uiMessageSender.showErrorDialog(VisaAPIError)
return@launch
}
.getOrElse {
onError(it)
return@launch
}
val result = tangemSdkManager.visaCustomerWalletApprove(
visaDataForApprove = VisaDataForApprove(
@ -85,26 +85,28 @@ internal class OnboardingVisaApproveModel @Inject constructor(
val resultData = when (result) {
is CompletionResult.Failure -> {
loading(false)
uiMessageSender.showErrorDialog(result.error.universalError)
analyticsEventHandler.send(VisaAnalyticsEvent.ErrorOnboarding(result.error.universalError))
onError(result.error.universalError)
return@launch
}
is CompletionResult.Success -> result.data
}
runCatching {
visaActivationRepository.approveByCustomerWallet(resultData)
}.onFailure {
loading(false)
uiMessageSender.showErrorDialog(VisaAPIError)
return@launch
}
visaActivationRepository.approveByCustomerWallet(resultData)
.onLeft {
onError(it)
return@launch
}
onDone.emit(Unit)
}
}
private fun onError(error: UniversalError) {
loading(false)
uiMessageSender.showErrorDialog(error)
analyticsEventHandler.send(VisaAnalyticsEvent.ErrorOnboarding(error))
}
private fun loading(state: Boolean) {
_uiState.update { it.copy(approveButtonLoading = state) }
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.onboarding.v2.visa.impl.child.inprogress.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
@ -12,8 +13,10 @@ import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.datasource.local.visa.VisaOTPStorage
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.visa.error.VisaActivationError
import com.tangem.domain.visa.error.VisaAuthorizationAPIError
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.wallets.builder.UserWalletBuilder
@ -64,9 +67,13 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
private fun runShortPolling() {
modelScope.launch {
while (true) {
val result = runCatching {
visaActivationRepository.getActivationRemoteState()
}.getOrNull() ?: continue
val result = visaActivationRepository.getActivationRemoteState()
.getOrElse {
uiMessageSender.showErrorDialog(it)
delay(timeMillis = 2000)
runShortPolling()
return@launch
}
when (result) {
is VisaActivationRemoteState.CardWalletSignatureRequired,
@ -156,12 +163,11 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
val authTokens = visaAuthTokenStorage.get(params.scanResponse.card.cardId)
?: error("Auth tokens are not found. This should not happen.")
val newTokens = runCatching {
visaAuthRepository.refreshAccessTokens(authTokens.refreshToken)
}.getOrElse {
uiMessageSender.showErrorDialog(VisaAuthorizationAPIError)
return
}
val newTokens = visaAuthRepository.refreshAccessTokens(authTokens.refreshToken)
.getOrElse {
uiMessageSender.showErrorDialog(it)
return
}
val userWallet = createUserWallet(params.scanResponse, newTokens)
userWalletsListManager.save(userWallet)

View file

@ -6,8 +6,10 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.utils.showErrorDialog
import com.tangem.domain.visa.model.VisaActivationOrderInfo
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.VisaCardId
@ -15,6 +17,7 @@ import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.OnboardingVisaOtherWalletComponent
import com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.ui.state.OnboardingVisaOtherWalletUM
import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent
import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.VisaAnalyticsEvent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
@ -25,6 +28,7 @@ import javax.inject.Inject
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class OnboardingVisaOtherWalletModel @Inject constructor(
paramsContainer: ParamsContainer,
visaActivationRepositoryFactory: VisaActivationRepository.Factory,
@ -32,6 +36,7 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor(
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
private val analyticsEventHandler: AnalyticsEventHandler,
private val uiMessageSender: UiMessageSender,
) : Model() {
private val config = paramsContainer.require<OnboardingVisaOtherWalletComponent.Config>()
@ -50,14 +55,18 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor(
analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.GoToWebsiteOpened)
modelScope.launch {
while (true) {
val result = runCatching {
visaActivationRepository.getActivationRemoteState()
}.getOrNull()
if (result is VisaActivationRemoteState.AwaitingPinCode) {
onDone.emit(result.activationOrderInfo)
break
}
visaActivationRepository.getActivationRemoteState()
.onLeft {
uiMessageSender.showErrorDialog(it)
analyticsEventHandler.send(VisaAnalyticsEvent.ErrorOnboarding(it))
delay(timeMillis = 60_000)
}
.onRight {
if (it is VisaActivationRemoteState.AwaitingPinCode) {
onDone.emit(it.activationOrderInfo)
return@launch
}
}
delay(timeMillis = 2000)
}

View file

@ -10,7 +10,6 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.showErrorDialog
import com.tangem.domain.visa.SetVisaPinCodeUseCase
import com.tangem.domain.visa.error.VisaAuthorizationAPIError
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.visa.impl.child.pincode.OnboardingVisaPinCodeComponent
@ -95,7 +94,7 @@ internal class OnboardingVisaPinCodeModel @Inject constructor(
activationOrderId = params.activationOrderInfo.orderId,
).onLeft {
loading(false)
uiMessageSender.showErrorDialog(VisaAuthorizationAPIError)
uiMessageSender.showErrorDialog(it)
return@launch
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.onboarding.v2.visa.impl.child.welcome.model
import arrow.core.getOrElse
import com.tangem.common.CompletionResult
import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -7,9 +8,9 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.error.UniversalError
import com.tangem.core.error.ext.universalError
import com.tangem.core.ui.utils.showErrorDialog
import com.tangem.domain.visa.error.VisaAPIError
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest
import com.tangem.domain.visa.repository.VisaActivationRepository
@ -78,13 +79,11 @@ internal class OnboardingVisaWelcomeModel @Inject constructor(
loading(true)
modelScope.launch {
val dataToSignByCardWallet = runCatching {
visaActivationRepository.getCardWalletAcceptanceData(params.dataToSignRequest)
}.getOrElse {
loading(false)
uiMessageSender.showErrorDialog(VisaAPIError)
return@launch
}
val dataToSignByCardWallet = visaActivationRepository.getCardWalletAcceptanceData(params.dataToSignRequest)
.getOrElse {
onError(it)
return@launch
}
val result = tangemSdkManager.activateVisaCard(
mode = VisaCardActivationTaskMode.SignOnly(dataToSignByCardWallet = dataToSignByCardWallet),
@ -93,21 +92,17 @@ internal class OnboardingVisaWelcomeModel @Inject constructor(
val resultData = when (result) {
is CompletionResult.Failure -> {
loading(false)
uiMessageSender.showErrorDialog(result.error.universalError)
analyticsEventsHandler.send(VisaAnalyticsEvent.ErrorOnboarding(result.error.universalError))
onError(result.error.universalError)
return@launch
}
is CompletionResult.Success -> result.data
}
runCatching {
visaActivationRepository.activateCard(resultData.signedActivationData)
}.onFailure {
loading(false)
uiMessageSender.showErrorDialog(VisaAPIError)
return@launch
}
visaActivationRepository.activateCard(resultData.signedActivationData)
.onLeft {
onError(it)
return@launch
}
val request = result.data.signedActivationData.dataToSign.request
val targetAddress = request.activationOrderInfo.customerWalletAddress
@ -132,6 +127,12 @@ internal class OnboardingVisaWelcomeModel @Inject constructor(
}
}
private fun onError(error: UniversalError) {
loading(false)
uiMessageSender.showErrorDialog(error)
analyticsEventsHandler.send(VisaAnalyticsEvent.ErrorOnboarding(error))
}
private fun loading(state: Boolean) {
_uiState.update { it.copy(continueButtonLoading = state) }
}