Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-22 11:52:10 +03:00
parent cb23c1dea3
commit 538757a7ce
54 changed files with 622 additions and 488 deletions

View file

@ -4,14 +4,12 @@ import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
import com.tangem.common.CompletionResult
import com.tangem.common.card.EllipticCurve
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.CompletionCallback
import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.common.core.*
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toDecompressedPublicKey
import com.tangem.common.extensions.toHexString
import com.tangem.common.map
import com.tangem.common.timemeasure.RealtimeMonotonicTimeSource
@ -46,7 +44,7 @@ import timber.log.Timber
import kotlin.coroutines.resume
import kotlin.time.measureTimedValue
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
class VisaCardActivationTask @AssistedInject constructor(
@Assisted private val mode: VisaCardActivationTaskMode,
@Assisted private val activationInput: VisaActivationInput,
@ -97,7 +95,23 @@ class VisaCardActivationTask @AssistedInject constructor(
context.signAuthorizationChallenge(mode.authorizationChallenge)
}
is VisaCardActivationTaskMode.SignOnly -> {
context.signData(mode.dataToSignByCardWallet)
val wallet =
card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 }
?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val derivedPublicKey = when (val deriveKeyResult = context.deriveKey(wallet.publicKey)) {
is CompletionResult.Failure -> {
return CompletionResult.Failure(deriveKeyResult.error)
}
is CompletionResult.Success -> {
deriveKeyResult.data
}
}
context.signData(
mode.dataToSignByCardWallet,
derivedPublicKey,
)
}
}
}
@ -138,9 +152,35 @@ class VisaCardActivationTask @AssistedInject constructor(
private suspend fun SessionContext.processSignedAuthorizationChallenge(
signedChallenge: VisaAuthSignedChallenge,
): CompletionResult<VisaCardActivationResponse> {
when (val createWalletResult = createWallet()) {
is CompletionResult.Failure -> return CompletionResult.Failure(createWalletResult.error)
is CompletionResult.Success -> {}
}
val card =
session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val wallet =
card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 }
?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val derivedPublicKey = when (val deriveKeyResult = deriveKey(wallet.publicKey)) {
is CompletionResult.Failure -> {
return CompletionResult.Failure(deriveKeyResult.error)
}
is CompletionResult.Success -> {
deriveKeyResult.data
}
}
val walletAddress = VisaWalletPublicKeyUtility.generateAddressOnSecp256k1(derivedPublicKey.publicKey)
.getOrElse { return CompletionResult.Failure(it.tangemError) }
.value
return coroutineScope {
val dataToSignDeferred = async { getDataToSign(signedChallenge) }
val otpTaskDeferred = async { createWallet() }
val dataToSignDeferred =
async { getDataToSign(signedChallenge = signedChallenge, cardWalletAddress = walletAddress) }
val otpTaskDeferred = async { createOTP() }
val dataToSign = dataToSignDeferred.await()
.getOrElse {
@ -150,12 +190,16 @@ class VisaCardActivationTask @AssistedInject constructor(
otpTaskDeferred.await()
signData(dataToSign)
signData(
dataToSign = dataToSign,
derivedPublicKey = derivedPublicKey,
)
}
}
private suspend fun SessionContext.getDataToSign(
signedChallenge: VisaAuthSignedChallenge,
cardWalletAddress: String,
): Either<TangemError, VisaDataToSignByCardWallet> = either {
catch(
block = {
@ -168,7 +212,12 @@ class VisaCardActivationTask @AssistedInject constructor(
raise(VisaActivationError.WrongRemoteState.tangemError)
}
visaActivationRepository.getCardWalletAcceptanceData(remoteState.request)
visaActivationRepository.getCardWalletAcceptanceData(
VisaCardWalletDataToSignRequest(
activationOrderInfo = remoteState.activationOrderInfo,
cardWalletAddress = cardWalletAddress,
),
)
},
catch = {
raise(VisaAuthorizationAPIError.tangemError)
@ -182,7 +231,7 @@ class VisaCardActivationTask @AssistedInject constructor(
val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
return if (card.wallets.any { it.curve == EllipticCurve.Secp256k1 }) {
createOTP()
CompletionResult.Success(Unit)
} else {
val createWalletTask = CreateWalletTask(EllipticCurve.Secp256k1)
@ -199,7 +248,7 @@ class VisaCardActivationTask @AssistedInject constructor(
when (val result = timedResult.value) {
is CompletionResult.Success -> {
Timber.i("CreateWalletTask success")
createOTP()
CompletionResult.Success(Unit)
}
is CompletionResult.Failure -> {
Timber.e("CreateWalletTask failure ${result.error}")
@ -245,26 +294,15 @@ class VisaCardActivationTask @AssistedInject constructor(
private suspend fun SessionContext.signData(
dataToSign: VisaDataToSignByCardWallet,
derivedPublicKey: ExtendedPublicKey,
): CompletionResult<VisaCardActivationResponse> {
val card =
session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val wallet =
card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 }
?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val derivedPublicKey = when (val deriveKeyResult = deriveKey(wallet.publicKey)) {
is CompletionResult.Failure -> {
return CompletionResult.Failure(deriveKeyResult.error)
}
is CompletionResult.Success -> {
deriveKeyResult.data
}
}
val walletAddress = VisaWalletPublicKeyUtility.generateAddressOnSecp256k1(derivedPublicKey.publicKey)
.getOrElse { return CompletionResult.Failure(it.tangemError) }
.value
val task = SignHashCommand(
hash = dataToSign.hashToSign.hexToBytes(),
walletPublicKey = wallet.publicKey,
@ -286,8 +324,8 @@ class VisaCardActivationTask @AssistedInject constructor(
Timber.i("SignHashCommand success")
handleSignedData(
dataToSign = dataToSign,
walletAddress = walletAddress,
response = result.data,
derivedPublicKey = derivedPublicKey,
)
}
is CompletionResult.Failure -> {
@ -313,7 +351,7 @@ class VisaCardActivationTask @AssistedInject constructor(
private suspend fun SessionContext.handleSignedData(
dataToSign: VisaDataToSignByCardWallet,
walletAddress: String,
derivedPublicKey: ExtendedPublicKey,
response: SignHashResponse,
): CompletionResult<VisaCardActivationResponse> {
val otp = otpStorage.getOTP(cardId) ?: run {
@ -321,11 +359,16 @@ class VisaCardActivationTask @AssistedInject constructor(
otpStorage.getOTP(cardId) ?: return CompletionResult.Failure(VisaActivationError.MissingRootOTP.tangemError)
}
val rsvSignature = EthereumUtils.prepareSignedMessageData(
signedHash = response.signature,
hashToSign = dataToSign.hashToSign.hexToBytes(),
publicKey = derivedPublicKey.publicKey.toDecompressedPublicKey(),
)
val signedActivationData = dataToSign.sign(
cardWalletAddress = walletAddress,
rootOTP = otp.rootOTP.toHexString(),
otpCounter = otp.counter,
signature = response.signature.toHexString(),
signature = rsvSignature,
)
return setupAccessCode().map {

View file

@ -1,6 +1,7 @@
package com.tangem.tap.domain.tasks.visa
import arrow.core.getOrElse
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
import com.tangem.common.CompletionResult
import com.tangem.common.card.Card
import com.tangem.common.card.CardWallet
@ -10,7 +11,7 @@ import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.CompletionCallback
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.common.extensions.toDecompressedPublicKey
import com.tangem.core.error.ext.tangemError
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
@ -129,6 +130,7 @@ class VisaCustomerWalletApproveTask(
targetWalletPublicKey = wallet.publicKey,
derivationPath = derivationPath,
session = session,
extendedPublicKey = extendedPublicKey,
callback = callback,
)
}
@ -149,6 +151,7 @@ class VisaCustomerWalletApproveTask(
signApproveData(
targetWalletPublicKey = publicKey,
derivationPath = null,
extendedPublicKey = null,
session = session,
callback = callback,
)
@ -157,11 +160,14 @@ class VisaCustomerWalletApproveTask(
private fun signApproveData(
targetWalletPublicKey: ByteArray,
derivationPath: DerivationPath?,
extendedPublicKey: ExtendedPublicKey?,
session: CardSession,
callback: CompletionCallback<VisaSignedDataByCustomerWallet>,
) {
val hashToSign = visaDataForApprove.dataToSign.hashToSign.hexToBytes()
val signTask = SignHashCommand(
hash = visaDataForApprove.dataToSign.hashToSign.hexToBytes(),
hash = hashToSign,
walletPublicKey = targetWalletPublicKey,
derivationPath = derivationPath,
)
@ -169,11 +175,18 @@ class VisaCustomerWalletApproveTask(
signTask.run(session) { result ->
when (result) {
is CompletionResult.Success -> {
val rsvSignature = EthereumUtils.prepareSignedMessageData(
signedHash = result.data.signature,
hashToSign = hashToSign,
publicKey = extendedPublicKey?.publicKey?.toDecompressedPublicKey()
?: targetWalletPublicKey.toDecompressedPublicKey(),
)
scanCard(
session = session,
callback = callback,
signedData = visaDataForApprove.dataToSign.sign(
signature = result.data.signature.toHexString(),
signature = rsvSignature,
customerWalletAddress = visaDataForApprove.targetAddress,
),
)

View file

@ -128,6 +128,8 @@ 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(
@ -180,6 +182,7 @@ internal class VisaCardScanHandler @Inject constructor(
return CompletionResult.Success(VisaCardActivationStatus.Activated(authorizationTokensResponse))
}
@Suppress("LongMethod")
private suspend fun SessionContext.handleCardAuthorization(
cardWalletAddress: String,
): CompletionResult<VisaCardActivationStatus> {
@ -232,11 +235,17 @@ internal class VisaCardScanHandler @Inject constructor(
tokens = authorizationTokensResponse,
)
val activationRemoteState = visaActivationRepository.getActivationRemoteState()
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 error = when (activationRemoteState) {
VisaActivationRemoteState.BlockedForActivation -> VisaActivationError.BlockedForActivation
VisaActivationRemoteState.Activated -> VisaActivationError.InvalidActivationState
VisaActivationRemoteState.Failed -> VisaActivationError.FailedRemoteState
else -> null
}

View file

@ -15,7 +15,7 @@ internal class TangemVisa(
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://bff.tangem.com/",
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)

View file

@ -15,7 +15,7 @@ internal class TangemVisaAuth(
private fun createStageEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.STAGE,
baseUrl = "https://api-s.tangem.org/",
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)

View file

@ -1,9 +1,11 @@
package com.tangem.datasource.api.visa
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.utils.ReadTimeout
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.response.*
import retrofit2.http.Body
@ -11,60 +13,40 @@ import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Query
import java.util.concurrent.TimeUnit
interface TangemVisaApi {
@GET("product_instance/activation_status")
@POST("v1/activation/status")
suspend fun getRemoteActivationStatus(
@Header("Authorization") authHeader: String,
@Query("customer_id") customerId: String,
@Query("product_instance_id") productInstanceId: String,
@Query("card_id") cardId: String,
@Query("card_public_key") cardPublicKey: String,
@Body request: ActivationStatusRequest,
): ApiResponse<CardActivationRemoteStateResponse>
@ReadTimeout(duration = 20, TimeUnit.MINUTES)
@GET("product_instance/activation_status")
suspend fun getRemoteActivationStatusLongPoll(
@Header("Authorization") authHeader: String,
@Query("customer_id") customerId: String,
@Query("product_instance_id") productInstanceId: String,
@Query("card_id") cardId: String,
@Query("card_public_key") cardPublicKey: String,
): ApiResponse<CardActivationRemoteStateResponse>
@GET("product_instance/card_wallet_acceptance")
@POST("v1/activation/acceptance/message")
suspend fun getCardWalletAcceptance(
@Header("Authorization") authHeader: String,
@Query("customer_id") customerId: String,
@Query("product_instance_id") productInstanceId: String,
@Query("activation_order") activationOrderId: String,
@Query("customer_wallet_address") customerWalletAddress: String,
): ApiResponse<CardWalletDataToSignResponse>
@Body request: GetCardWalletAcceptanceRequest,
): ApiResponse<VisaDataToSignResponse>
@GET("product_instance/customer_wallet_acceptance")
@POST("v1/activation/acceptance/message")
suspend fun getCustomerWalletAcceptance(
@Header("Authorization") authHeader: String,
@Query("customer_id") customerId: String,
@Query("product_instance_id") productInstanceId: String,
@Query("activation_order") activationOrderId: String,
@Query("card_wallet_address") cardWalletAddress: String,
): ApiResponse<CustomerWalletDataToSignResponse>
@Body request: GetCustomerWalletAcceptanceRequest,
): ApiResponse<VisaDataToSignResponse>
@POST("product_instance/activation_by_card_wallet")
@POST("v1/activation/data")
suspend fun activateByCardWallet(
@Header("Authorization") authHeader: String,
@Body body: ActivationByCardWalletRequest,
): ApiResponse<Unit>
@POST("product_instance/activation_by_customer_wallet")
@POST("v1/activation/data")
suspend fun activateByCustomerWallet(
@Header("Authorization") authHeader: String,
@Body body: ActivationByCustomerWalletRequest,
): ApiResponse<Unit>
@POST("product_instance/issuer_activation")
@POST("v1/activation/pin")
suspend fun setPinCode(
@Header("Authorization") authHeader: String,
@Body body: SetPinCodeRequest,

View file

@ -1,32 +1,29 @@
package com.tangem.datasource.api.visa
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.visa.models.request.*
import com.tangem.datasource.api.visa.models.response.GenerateNonceResponse
import com.tangem.datasource.api.visa.models.response.JWTResponse
import retrofit2.http.Field
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Query
interface TangemVisaAuthApi {
@POST("auth/card_wallet")
suspend fun generateNonceByWalletAddress(
@Query("card_wallet_address") cardWalletAddress: String,
): GenerateNonceResponse
@POST("v1/auth/challenge")
suspend fun generateNonceByCardId(@Body request: GenerateNoneByCardIdRequest): GenerateNonceResponse
@POST("auth/card_id")
suspend fun generateNonceByCard(
@Query("card_id") cardId: String,
@Query("card_public_key") cardPublicKey: String,
): GenerateNonceResponse
@POST("v1/auth/challenge")
suspend fun generateNonceByCardWallet(@Body request: GenerateNoneByCardWalletRequest): GenerateNonceResponse
@POST("auth/get_token")
suspend fun getAccessToken(
@Query("session_id") sessionId: String,
@Query("signature") signature: String,
@Query("salt") salt: String?,
): JWTResponse
@POST("v1/auth/token")
suspend fun getAccessTokenByCardId(@Body request: GetAccessTokenByCardIdRequest): JWTResponse
@POST("auth/refresh_token")
suspend fun refreshAccessToken(@Field("refresh_token") refreshToken: String): ApiResponse<JWTResponse>
@POST("v1/auth/token")
suspend fun getAccessTokenByCardWallet(@Body request: GetAccessTokenByCardWalletRequest): JWTResponse
@POST("v1/auth/token/refresh")
suspend fun refreshCardIdAccessToken(@Body request: RefreshTokenByCardIdRequest): ApiResponse<JWTResponse>
@POST("v1/auth/token/refresh")
suspend fun refreshCardWalletAccessToken(@Body request: RefreshTokenByCardWalletRequest): ApiResponse<JWTResponse>
}

View file

@ -5,22 +5,15 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ActivationByCardWalletRequest(
@Json(name = "customer_id") val customerId: String,
@Json(name = "product_instance_id") val productInstanceId: String,
@Json(name = "activation_order_id") val activationOrderId: String,
@Json(name = "data") val data: Data,
@Json(name = "order_id") val orderId: String,
@Json(name = "card_wallet") val cardWallet: CardWallet,
@Json(name = "deploy_acceptance_signature") val deployAcceptanceSignature: String,
@Json(name = "otp") val otp: Otp,
) {
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "card_wallet") val cardWallet: CardWallet,
@Json(name = "otp") val otp: Otp,
)
@JsonClass(generateAdapter = true)
data class CardWallet(
@Json(name = "address") val address: String,
@Json(name = "card_wallet_confirmation") val cardWalletConfirmation: CardWalletConfirmation?,
@Json(name = "deploy_acceptance_signature") val deployAcceptanceSignature: String,
)
@JsonClass(generateAdapter = true)

View file

@ -5,19 +5,12 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ActivationByCustomerWalletRequest(
@Json(name = "customer_id") val customerId: String,
@Json(name = "product_instance_id") val productInstanceId: String,
@Json(name = "activation_order_id") val activationOrderId: String,
@Json(name = "data") val data: Data,
@Json(name = "order_id") val orderId: String,
@Json(name = "customer_wallet") val customerWallet: CustomerWallet,
) {
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "customer_wallet") val customerWallet: CustomerWallet,
)
@JsonClass(generateAdapter = true)
data class CustomerWallet(
@Json(name = "address") val address: String,
@Json(name = "deploy_acceptance_signature") val deployAcceptanceSignature: String,
@Json(name = "address") val customerWalletAddress: String,
)
}

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ActivationStatusRequest(
@Json(name = "card_id") val cardId: String,
@Json(name = "card_public_key") val cardPublicKey: String,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GenerateNoneByCardIdRequest(
@Json(name = "auth_type") val authType: String = "card_id",
@Json(name = "card_id") val cardId: String,
@Json(name = "card_public_key") val cardPublicKey: String,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GenerateNoneByCardWalletRequest(
@Json(name = "auth_type") val authType: String = "card_wallet",
@Json(name = "card_wallet_address") val cardWalletAddress: String,
)

View file

@ -0,0 +1,12 @@
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GetAccessTokenByCardIdRequest(
@Json(name = "auth_type") val authType: String = "card_id",
@Json(name = "session_id") val sessionId: String,
@Json(name = "signature") val signature: String,
@Json(name = "salt") val salt: String,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GetAccessTokenByCardWalletRequest(
@Json(name = "auth_type") val authType: String = "card_wallet",
@Json(name = "session_id") val sessionId: String,
@Json(name = "signature") val signature: String,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GetCardWalletAcceptanceRequest(
@Json(name = "type") val type: String = "card_wallet",
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
@Json(name = "card_wallet_address") val cardWalletAddress: String,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GetCustomerWalletAcceptanceRequest(
@Json(name = "type") val type: String = "customer_wallet",
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
@Json(name = "card_wallet_address") val cardWalletAddress: String,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class RefreshTokenByCardIdRequest(
@Json(name = "auth_type") val authType: String = "card_id",
@Json(name = "refresh_token") val refreshToken: String,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class RefreshTokenByCardWalletRequest(
@Json(name = "auth_type") val authType: String = "card_wallet",
@Json(name = "refresh_token") val refreshToken: String,
)

View file

@ -5,14 +5,8 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SetPinCodeRequest(
@Json(name = "customer_id") val customerId: String,
@Json(name = "activation_order_id") val activationOrderId: String,
@Json(name = "product_instance_id") val productInstanceId: String,
@Json(name = "data") val data: Data,
) {
data class Data(
@Json(name = "session_key") val sessionKey: String,
@Json(name = "iv") val iv: String,
@Json(name = "encrypted_pin") val encryptedPin: String,
)
}
@Json(name = "order_id") val orderId: String,
@Json(name = "session_id") val sessionId: String,
@Json(name = "iv") val iv: String,
@Json(name = "pin") val pin: String,
)

View file

@ -5,15 +5,21 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CardActivationRemoteStateResponse(
@Json(name = "activation_status") val status: String,
@Json(name = "activation_order") val activationOrder: ActivationOrder?,
@Json(name = "stepChangeCode") val stepChangeCode: Int?,
@Json(name = "updatedAt") val updatedAt: String?,
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "status") val status: String,
@Json(name = "order") val activationOrder: ActivationOrder?,
@Json(name = "stepChangeCode") val stepChangeCode: Int?,
@Json(name = "updatedAt") val updatedAt: String?,
)
@JsonClass(generateAdapter = true)
data class ActivationOrder(
@Json(name = "id") val id: String,
@Json(name = "customer_id") val customerId: String,
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
@Json(name = "card_wallet_address") val cardWalletAddress: String,
)
}

View file

@ -1,14 +0,0 @@
package com.tangem.datasource.api.visa.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CustomerWalletDataToSignResponse(
@Json(name = "data_for_customer_wallet") val dataForCardWallet: Data,
) {
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "hash") val hash: String,
)
}

View file

@ -5,6 +5,11 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GenerateNonceResponse(
@Json(name = "nonce") val nonce: String,
@Json(name = "session_id") val sessionId: String,
)
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "nonce") val nonce: String,
@Json(name = "session_id") val sessionId: String,
)
}

View file

@ -5,12 +5,17 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class JWTResponse(
@Json(name = "access_token") val accessToken: String,
@Json(name = "expires_in") val expiresIn: Int,
@Json(name = "refresh_expires_in") val refreshExpiresIn: Int,
@Json(name = "refresh_token") val refreshToken: String,
@Json(name = "token_type") val tokenType: String,
@Json(name = "not-before-policy") val notBeforePolicy: Int,
@Json(name = "session_state") val sessionState: String,
@Json(name = "scope") val scope: String,
)
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "access_token") val accessToken: String,
@Json(name = "expires_in") val expiresIn: Int,
@Json(name = "refresh_expires_in") val refreshExpiresIn: Int,
@Json(name = "refresh_token") val refreshToken: String,
@Json(name = "token_type") val tokenType: String,
@Json(name = "not-before-policy") val notBeforePolicy: Int,
@Json(name = "session_state") val sessionState: String,
@Json(name = "scope") val scope: String,
)
}

View file

@ -4,11 +4,11 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CardWalletDataToSignResponse(
@Json(name = "dataForCardWallet") val dataForCardWallet: Data,
data class VisaDataToSignResponse(
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)
data class Data(
data class Result(
@Json(name = "hash") val hash: String,
)
}

View file

@ -186,7 +186,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
id = ApiConfig.ID.TangemVisaAuth,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.STAGE,
baseUrl = "https://api-s.tangem.org/",
baseUrl = "[REDACTED_ENV_URL]",
headers = mapOf(
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },
@ -200,7 +200,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
id = ApiConfig.ID.TangemVisa,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://bff.tangem.com/",
baseUrl = "[REDACTED_ENV_URL]",
headers = mapOf(
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },

View file

@ -1,13 +1,15 @@
package com.tangem.data.visa
import com.tangem.data.visa.config.VisaLibLoader
import com.tangem.data.visa.converter.AccessCodeDataConverter
import com.tangem.data.visa.converter.VisaActivationStatusConverter
import com.tangem.data.visa.converter.VisaActivationStatusConverterWithState
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.local.visa.VisaAuthTokenStorage
import com.tangem.domain.visa.exception.RefreshTokenExpiredException
@ -25,9 +27,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
@Assisted private val visaCardId: VisaCardId,
private val visaApi: TangemVisaApi,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val visaActivationStatusConverter: VisaActivationStatusConverter,
private val visaAuthTokenStorage: VisaAuthTokenStorage,
private val accessCodeDataConverter: AccessCodeDataConverter,
private val visaAuthRepository: VisaAuthRepository,
private val visaLibLoader: VisaLibLoader,
) : VisaActivationRepository {
@ -36,59 +36,38 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getRemoteActivationStatus(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
cardId = visaCardId.cardId,
cardPublicKey = visaCardId.cardPublicKey,
request = ActivationStatusRequest(
cardId = visaCardId.cardId,
cardPublicKey = visaCardId.cardPublicKey,
),
).getOrThrow()
}
visaActivationStatusConverter.convert(result)
VisaActivationStatusConverterWithState.convert(result)
}
override suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState =
withContext(dispatcherProvider.io) {
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getRemoteActivationStatusLongPoll(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
cardId = visaCardId.cardId,
cardPublicKey = visaCardId.cardPublicKey,
).getOrThrow()
}
visaActivationStatusConverter.convert(result)
}
override suspend fun getCardWalletAcceptanceData(
request: VisaCardWalletDataToSignRequest,
): VisaDataToSignByCardWallet = withContext(dispatcherProvider.io) {
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getCardWalletAcceptance(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = request.orderId,
customerWalletAddress = request.customerWalletAddress,
request = GetCardWalletAcceptanceRequest(
customerWalletAddress = request.activationOrderInfo.customerWalletAddress,
cardWalletAddress = request.cardWalletAddress,
),
).getOrThrow()
}
VisaDataToSignByCardWallet(
request = request,
hashToSign = result.dataForCardWallet.hash,
hashToSign = result.result.hash,
)
}
@ -98,20 +77,19 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getCustomerWalletAcceptance(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = request.orderId,
cardWalletAddress = request.cardWalletAddress,
request = GetCustomerWalletAcceptanceRequest(
cardWalletAddress = request.cardWalletAddress,
customerWalletAddress = request.customerWalletAddress,
),
).getOrThrow()
}
VisaDataToSignByCustomerWallet(
request = request,
hashToSign = result.dataForCardWallet.hash,
hashToSign = result.result.hash,
)
}
@ -120,27 +98,22 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.activateByCardWallet(
authHeader = authTokens.getAuthHeader(),
body = ActivationByCardWalletRequest(
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = signedData.dataToSign.request.orderId,
data = ActivationByCardWalletRequest.Data(
cardWallet = ActivationByCardWalletRequest.CardWallet(
address = signedData.cardWalletAddress,
cardWalletConfirmation = null, // for second iteration
deployAcceptanceSignature = signedData.signature,
),
otp = ActivationByCardWalletRequest.Otp(
rootOtp = signedData.rootOTP,
counter = signedData.otpCounter,
),
orderId = signedData.dataToSign.request.activationOrderInfo.orderId,
cardWallet = ActivationByCardWalletRequest.CardWallet(
address = signedData.dataToSign.request.cardWalletAddress,
cardWalletConfirmation = null, // for second iteration
),
deployAcceptanceSignature = signedData.signature,
otp = ActivationByCardWalletRequest.Otp(
rootOtp = signedData.rootOTP,
counter = signedData.otpCounter,
),
),
)
).getOrThrow()
}
}
}
@ -150,22 +123,17 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.activateByCustomerWallet(
authHeader = authTokens.getAuthHeader(),
body = ActivationByCustomerWalletRequest(
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = signedData.dataToSign.request.orderId,
data = ActivationByCustomerWalletRequest.Data(
customerWallet = ActivationByCustomerWalletRequest.CustomerWallet(
address = signedData.customerWalletAddress,
deployAcceptanceSignature = signedData.signature,
),
orderId = signedData.dataToSign.request.orderId,
customerWallet = ActivationByCustomerWalletRequest.CustomerWallet(
deployAcceptanceSignature = signedData.signature,
customerWalletAddress = signedData.customerWalletAddress,
),
),
)
).getOrThrow()
}
}
}
@ -175,21 +143,16 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.setPinCode(
authHeader = authTokens.getAuthHeader(),
body = SetPinCodeRequest(
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = pinCode.activationOrderId,
data = SetPinCodeRequest.Data(
sessionKey = pinCode.sessionId,
iv = pinCode.iv,
encryptedPin = pinCode.encryptedPin,
),
orderId = pinCode.activationOrderId,
sessionId = pinCode.sessionId,
iv = pinCode.iv,
pin = pinCode.encryptedPin,
),
)
).getOrThrow()
}
}
}

View file

@ -1,8 +1,8 @@
package com.tangem.data.visa
import com.tangem.common.extensions.toHexString
import com.tangem.crypto.CryptoUtils
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.visa.TangemVisaAuthApi
import com.tangem.datasource.api.visa.models.request.*
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaAuthSession
import com.tangem.domain.visa.model.VisaAuthSignedChallenge
@ -20,74 +20,80 @@ internal class DefaultVisaAuthRepository @Inject constructor(
override suspend fun getCardAuthChallenge(cardId: String, cardPublicKey: String): VisaAuthChallenge.Card =
withContext(dispatchers.io) {
// val response = visaAuthApi.generateNonceByCard(
// cardId = cardId,
// cardPublicKey = cardPublicKey,
// )
//
// VisaAuthChallenge.Card(
// challenge = response.nonce,
// session = VisaAuthSession(response.sessionId),
// )
val response = visaAuthApi.generateNonceByCardId(
GenerateNoneByCardIdRequest(
cardId = cardId,
cardPublicKey = cardPublicKey,
),
)
VisaAuthChallenge.Card(
challenge = CryptoUtils.generateRandomBytes(length = 16).toHexString(),
session = VisaAuthSession("session"),
challenge = response.result.nonce,
session = VisaAuthSession(response.result.sessionId),
)
}
override suspend fun getCardWalletAuthChallenge(cardWalletAddress: String): VisaAuthChallenge.Wallet =
withContext(dispatchers.io) {
// val response = visaAuthApi.generateNonceByWalletAddress(
// customerId = cardId,
// customerWalletAddress = walletPublicKey,
// )
//
// VisaAuthChallenge.Wallet(
// challenge = response.nonce,
// session = VisaAuthSession(response.sessionId),
// )
val response = visaAuthApi.generateNonceByCardWallet(
GenerateNoneByCardWalletRequest(
cardWalletAddress = cardWalletAddress,
),
)
VisaAuthChallenge.Wallet(
challenge = CryptoUtils.generateRandomBytes(length = 32).toHexString(),
session = VisaAuthSession("session"),
challenge = response.result.nonce,
session = VisaAuthSession(response.result.sessionId),
)
}
override suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): VisaAuthTokens =
withContext(dispatchers.io) {
// val response = when (signedChallenge) {
// is VisaAuthSignedChallenge.ByCardPublicKey -> {
// visaAuthApi.getAccessToken(
// sessionId = signedChallenge.challenge.session.sessionId,
// signature = signedChallenge.signature,
// salt = signedChallenge.salt,
// )
// }
// is VisaAuthSignedChallenge.ByWallet -> {
// visaAuthApi.getAccessToken(
// sessionId = signedChallenge.challenge.session.sessionId,
// signature = signedChallenge.signature,
// salt = null,
// )
// }
// }
//
// VisaAuthTokens(
// accessToken = response.accessToken,
// refreshToken = response.refreshToken,
// )
val response = when (signedChallenge) {
is VisaAuthSignedChallenge.ByCardPublicKey -> {
visaAuthApi.getAccessTokenByCardId(
GetAccessTokenByCardIdRequest(
sessionId = signedChallenge.challenge.session.sessionId,
signature = signedChallenge.signature,
salt = signedChallenge.salt,
),
)
}
is VisaAuthSignedChallenge.ByWallet -> {
visaAuthApi.getAccessTokenByCardWallet(
GetAccessTokenByCardWalletRequest(
sessionId = signedChallenge.challenge.session.sessionId,
signature = signedChallenge.signature,
),
)
}
}
VisaAuthTokens(
accessToken = "accessToken",
refreshToken = VisaAuthTokens.RefreshToken("refreshToken"),
accessToken = response.result.accessToken,
refreshToken = VisaAuthTokens.RefreshToken(
value = response.result.refreshToken,
authType = when (signedChallenge) {
is VisaAuthSignedChallenge.ByCardPublicKey -> VisaAuthTokens.RefreshToken.Type.CardId
is VisaAuthSignedChallenge.ByWallet -> VisaAuthTokens.RefreshToken.Type.CardWallet
},
),
)
}
override suspend fun refreshAccessTokens(refreshToken: VisaAuthTokens.RefreshToken): VisaAuthTokens =
withContext(dispatchers.io) {
// TODO
val response = when (refreshToken.authType) {
VisaAuthTokens.RefreshToken.Type.CardId ->
visaAuthApi.refreshCardIdAccessToken(
RefreshTokenByCardIdRequest(refreshToken = refreshToken.value),
)
VisaAuthTokens.RefreshToken.Type.CardWallet ->
visaAuthApi.refreshCardIdAccessToken(
RefreshTokenByCardIdRequest(refreshToken = refreshToken.value),
)
}.getOrThrow()
VisaAuthTokens(
accessToken = "accessToken",
refreshToken = VisaAuthTokens.RefreshToken("new refreshToken"),
accessToken = response.result.accessToken,
refreshToken = refreshToken.copy(value = response.result.refreshToken),
)
}
}

View file

@ -15,18 +15,15 @@ class MockVisaActivationRepository @AssistedInject constructor(
override suspend fun getActivationRemoteState(): VisaActivationRemoteState {
return VisaActivationRemoteState.CardWalletSignatureRequired(
request = VisaCardWalletDataToSignRequest(
activationOrderInfo = VisaActivationOrderInfo(
orderId = "orderId",
customerId = "customerId",
customerWalletAddress = "customerWalletAddress",
cardWalletAddress = null,
),
)
}
override suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState {
return VisaActivationRemoteState.PaymentAccountDeploying
}
override suspend fun getCardWalletAcceptanceData(
request: VisaCardWalletDataToSignRequest,
): VisaDataToSignByCardWallet {

View file

@ -1,60 +0,0 @@
package com.tangem.data.visa.converter
import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse
import com.tangem.domain.visa.model.VisaActivationOrderInfo
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.utils.converter.Converter
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VisaActivationStatusConverter @Inject constructor() :
Converter<CardActivationRemoteStateResponse, VisaActivationRemoteState> {
private val lastUpdatedAt = MutableStateFlow<String?>(null)
override fun convert(value: CardActivationRemoteStateResponse): VisaActivationRemoteState {
// handle pin code error
// either we entered pin code before with an error (WasError) or after receiving an error (InProgress)
if (
value.status == Status.AwaitingPin.stringValue &&
value.stepChangeCode != null &&
value.stepChangeCode == PIN_CODE_VALIDATION_ERROR
) {
return if (lastUpdatedAt.value != value.updatedAt) {
lastUpdatedAt.value == value.updatedAt
VisaActivationRemoteState.AwaitingPinCode(
activationOrderInfo = value.activationOrder!!.convert(),
status = VisaActivationRemoteState.AwaitingPinCode.Status.WasError,
)
} else {
VisaActivationRemoteState.AwaitingPinCode(
activationOrderInfo = value.activationOrder!!.convert(),
status = VisaActivationRemoteState.AwaitingPinCode.Status.InProgress,
)
}
}
// TODO Will be implemented in the future
return VisaActivationRemoteState.Activated
}
private fun CardActivationRemoteStateResponse.ActivationOrder.convert(): VisaActivationOrderInfo {
return VisaActivationOrderInfo(
orderId = id,
customerId = customerId,
customerWalletAddress = customerWalletAddress,
)
}
private enum class Status(val stringValue: String) {
AwaitingPin("AWAITING_PIN"),
// TODO complete statuses list when backend is ready
}
private companion object {
const val PIN_CODE_VALIDATION_ERROR = 1000
}
}

View file

@ -0,0 +1,81 @@
package com.tangem.data.visa.converter
import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse
import com.tangem.domain.visa.model.VisaActivationOrderInfo
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.utils.converter.Converter
import kotlinx.coroutines.flow.MutableStateFlow
private const val PIN_CODE_VALIDATION_ERROR = 1000
object VisaActivationStatusConverterWithState :
Converter<CardActivationRemoteStateResponse, VisaActivationRemoteState> {
private val lastUpdatedAt = MutableStateFlow<String?>(null)
override fun convert(value: CardActivationRemoteStateResponse): VisaActivationRemoteState {
// handle pin code error
// either we entered pin code before with an error (WasError) or after receiving an error (InProgress)
// TODO [REDACTED_TASK_KEY]
val result = value.result
if (result.status == Status.PinCodeRequired.stringValue && result.stepChangeCode == PIN_CODE_VALIDATION_ERROR) {
return if (lastUpdatedAt.value != result.updatedAt) {
lastUpdatedAt.value = result.updatedAt
VisaActivationRemoteState.AwaitingPinCode(
activationOrderInfo = result.activationOrder!!.convert(),
status = VisaActivationRemoteState.AwaitingPinCode.Status.WasError,
)
} else {
VisaActivationRemoteState.AwaitingPinCode(
activationOrderInfo = result.activationOrder!!.convert(),
status = VisaActivationRemoteState.AwaitingPinCode.Status.InProgress,
)
}
}
val status = Status.entries.find { it.stringValue == result.status } ?: error(
"Unknown status: ${result.status}",
)
return when (status) {
Status.Activated -> VisaActivationRemoteState.Activated
Status.Failed -> VisaActivationRemoteState.Failed
Status.BlockedForActivation -> VisaActivationRemoteState.BlockedForActivation
Status.CardWalletSignatureRequired ->
VisaActivationRemoteState.CardWalletSignatureRequired(
activationOrderInfo = result.activationOrder!!.convert(),
)
Status.CustomerWalletSignatureRequired ->
VisaActivationRemoteState.CustomerWalletSignatureRequired(
activationOrderInfo = result.activationOrder!!.convert(),
)
Status.PaymentAccountDeploying -> VisaActivationRemoteState.PaymentAccountDeploying
Status.PinCodeRequired -> VisaActivationRemoteState.AwaitingPinCode(
activationOrderInfo = result.activationOrder!!.convert(),
status = VisaActivationRemoteState.AwaitingPinCode.Status.WaitingForPinCode,
)
Status.WaitingForActivation -> VisaActivationRemoteState.WaitingForActivationFinishing
}
}
private fun CardActivationRemoteStateResponse.ActivationOrder.convert(): VisaActivationOrderInfo {
return VisaActivationOrderInfo(
orderId = id,
customerId = customerId,
customerWalletAddress = customerWalletAddress,
cardWalletAddress = cardWalletAddress.takeIf { it.isNotBlank() },
)
}
private enum class Status(val stringValue: String) {
Activated("activated"),
Failed("failed"),
BlockedForActivation("blocked_for_activation"),
CardWalletSignatureRequired("card_wallet_signature_required"),
CustomerWalletSignatureRequired("customer_wallet_signature_required"),
PaymentAccountDeploying("payment_account_deploying"),
PinCodeRequired("pin_code_required"),
WaitingForActivation("waiting_for_activation"),
}
}

View file

@ -1,8 +1,8 @@
package com.tangem.data.visa.di
import com.tangem.data.visa.DefaultVisaActivationRepository
import com.tangem.data.visa.DefaultVisaAuthRepository
import com.tangem.data.visa.MockVisaRepository
import com.tangem.data.visa.MockVisaActivationRepository
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.visa.repository.VisaRepository
@ -20,19 +20,19 @@ internal interface VisaDataModule {
@Singleton
fun bindVisaAuthRepository(repository: DefaultVisaAuthRepository): VisaAuthRepository
// @Binds
// @Singleton
// fun bindVisaActivationRepositoryFactory(
// repository: DefaultVisaActivationRepository.Factory,
// ): VisaActivationRepository.Factory
// Mocked
@Binds
@Singleton
fun bindVisaActivationRepositoryFactory(
repository: MockVisaActivationRepository.Factory,
repository: DefaultVisaActivationRepository.Factory,
): VisaActivationRepository.Factory
// Mocked
// @Binds
// @Singleton
// fun bindVisaActivationRepositoryFactory(
// repository: MockVisaActivationRepository.Factory,
// ): VisaActivationRepository.Factory
// @Binds
// fun bindVisaRepository(repository: DefaultVisaRepository): VisaRepository

View file

@ -6,6 +6,7 @@ import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.visa.TangemVisaAuthApi
import com.tangem.datasource.api.visa.models.request.RefreshTokenByCardWalletRequest
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.visa.exception.RefreshTokenExpiredException
@ -79,16 +80,18 @@ internal class VisaApiRequestMaker @Inject constructor(
}
private suspend fun refreshAccessTokens(refreshToken: VisaAuthTokens.RefreshToken): VisaAuthTokens {
val result = visaAuthApi.refreshAccessToken(refreshToken.value).getOrThrow()
val result = visaAuthApi.refreshCardWalletAccessToken(
RefreshTokenByCardWalletRequest(refreshToken = refreshToken.value),
).getOrThrow()
return VisaAuthTokens(
accessToken = result.accessToken,
refreshToken = VisaAuthTokens.RefreshToken(result.refreshToken),
accessToken = result.result.accessToken,
refreshToken = refreshToken.copy(value = result.result.refreshToken),
)
}
@Throws
private suspend fun getAuthTokens(userWalletId: UserWalletId): VisaAuthTokens {
private fun getAuthTokens(userWalletId: UserWalletId): VisaAuthTokens {
val userWallet = findVisaUserWallet(userWalletId)
val status = userWallet.scanResponse.visaCardActivationStatus ?: error("Visa card activation status not found")
@ -99,7 +102,7 @@ internal class VisaApiRequestMaker @Inject constructor(
return (status as? VisaCardActivationStatus.Activated)?.visaAuthTokens ?: error("Visa card is not activated")
}
private suspend fun findVisaUserWallet(userWalletId: UserWalletId): UserWallet {
private fun findVisaUserWallet(userWalletId: UserWalletId): UserWallet {
val userWallet = requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"No user wallet found: $userWalletId"
}

View file

@ -57,7 +57,7 @@ object VisaWalletPublicKeyUtility {
either {
val address = generateAddressOnSecp256k1(publicKey).bind()
if (address.value != targetAddress) {
if (address.value.lowercase() != targetAddress.lowercase()) {
raise(VisaActivationError.AddressNotMatched)
}
}

View file

@ -20,6 +20,9 @@ dependencies {
implementation(projects.domain.tokens.models)
implementation(projects.domain.appCurrency.models)
/** Security */
implementation(deps.spongecastle.core)
/** Libs - Other */
implementation(deps.jodatime)
implementation(deps.androidx.paging.runtime)

View file

@ -44,6 +44,7 @@ enum class VisaActivationError(
FailedToCreateAddress(104003007),
AddressNotMatched(104003008),
InconsistentRemoteState(104003009),
FailedRemoteState(104003010),
}
object VisaAuthorizationAPIError : UniversalError {

View file

@ -10,4 +10,5 @@ data class VisaActivationOrderInfo(
@Json(name = "orderId") val orderId: String,
@Json(name = "customerId") val customerId: String,
@Json(name = "customerWalletAddress") val customerWalletAddress: String,
@Json(name = "cardWalletAddress") val cardWalletAddress: String?,
)

View file

@ -1,6 +1,7 @@
package com.tangem.domain.visa.model
import com.squareup.moshi.*
import com.tangem.domain.visa.model.VisaActivationRemoteState.*
import kotlinx.serialization.Serializable
@Serializable
@ -8,7 +9,7 @@ sealed class VisaActivationRemoteState {
@Serializable
data class CardWalletSignatureRequired(
val request: VisaCardWalletDataToSignRequest,
val activationOrderInfo: VisaActivationOrderInfo,
) : VisaActivationRemoteState()
@Serializable
@ -38,6 +39,9 @@ sealed class VisaActivationRemoteState {
@Serializable
data object BlockedForActivation : VisaActivationRemoteState()
@Serializable
data object Failed : VisaActivationRemoteState()
companion object {
val jsonAdapter: VisaActivationRemoteState_JsonAdapter = VisaActivationRemoteState_JsonAdapter()
}
@ -46,7 +50,6 @@ sealed class VisaActivationRemoteState {
@Suppress("ClassNaming")
class VisaActivationRemoteState_Json(
@Json(name = "type") val type: VisaActivationRemoteState_Type,
@Json(name = "requestCardWallet") val requestCardWallet: VisaCardWalletDataToSignRequest? = null,
@Json(name = "activationOrderInfo") val activationOrderInfo: VisaActivationOrderInfo? = null,
@Json(name = "awaiting_pin_code_status") val awaitingPinCodeStatus: AwaitingPinCodeStatus_Type? = null,
)
@ -62,6 +65,7 @@ enum class VisaActivationRemoteState_Type {
WaitingForActivationFinishing,
Activated,
BlockedForActivation,
Failed,
}
@Suppress("ClassNaming")
@ -77,64 +81,66 @@ class VisaActivationRemoteState_JsonAdapter {
fun fromJson(value: VisaActivationRemoteState_Json): VisaActivationRemoteState {
return when (value.type) {
VisaActivationRemoteState_Type.CardWalletSignatureRequired ->
VisaActivationRemoteState.CardWalletSignatureRequired(value.requestCardWallet!!)
CardWalletSignatureRequired(value.activationOrderInfo!!)
VisaActivationRemoteState_Type.CustomerWalletSignatureRequired ->
VisaActivationRemoteState.CustomerWalletSignatureRequired(value.activationOrderInfo!!)
VisaActivationRemoteState_Type.PaymentAccountDeploying -> VisaActivationRemoteState.PaymentAccountDeploying
CustomerWalletSignatureRequired(value.activationOrderInfo!!)
VisaActivationRemoteState_Type.PaymentAccountDeploying -> PaymentAccountDeploying
VisaActivationRemoteState_Type.WaitingPinCode ->
VisaActivationRemoteState.AwaitingPinCode(
AwaitingPinCode(
activationOrderInfo = value.activationOrderInfo!!,
status = when (value.awaitingPinCodeStatus!!) {
AwaitingPinCodeStatus_Type.WaitingForPinCode ->
VisaActivationRemoteState.AwaitingPinCode.Status.WaitingForPinCode
AwaitingPinCode.Status.WaitingForPinCode
AwaitingPinCodeStatus_Type.InProgress ->
VisaActivationRemoteState.AwaitingPinCode.Status.InProgress
AwaitingPinCode.Status.InProgress
AwaitingPinCodeStatus_Type.WasError ->
VisaActivationRemoteState.AwaitingPinCode.Status.WasError
AwaitingPinCode.Status.WasError
},
)
VisaActivationRemoteState_Type.WaitingForActivationFinishing ->
VisaActivationRemoteState.WaitingForActivationFinishing
VisaActivationRemoteState_Type.Activated -> VisaActivationRemoteState.Activated
VisaActivationRemoteState_Type.BlockedForActivation -> VisaActivationRemoteState.BlockedForActivation
WaitingForActivationFinishing
VisaActivationRemoteState_Type.Activated -> Activated
VisaActivationRemoteState_Type.BlockedForActivation -> BlockedForActivation
VisaActivationRemoteState_Type.Failed -> Failed
}
}
@ToJson
fun toJson(value: VisaActivationRemoteState): VisaActivationRemoteState_Json {
return when (value) {
is VisaActivationRemoteState.CardWalletSignatureRequired ->
is CardWalletSignatureRequired ->
VisaActivationRemoteState_Json(
type = VisaActivationRemoteState_Type.CardWalletSignatureRequired,
requestCardWallet = value.request,
activationOrderInfo = value.activationOrderInfo,
)
is VisaActivationRemoteState.CustomerWalletSignatureRequired ->
is CustomerWalletSignatureRequired ->
VisaActivationRemoteState_Json(
type = VisaActivationRemoteState_Type.CustomerWalletSignatureRequired,
activationOrderInfo = value.activationOrderInfo,
)
is VisaActivationRemoteState.PaymentAccountDeploying ->
is PaymentAccountDeploying ->
VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.PaymentAccountDeploying)
is VisaActivationRemoteState.AwaitingPinCode ->
is AwaitingPinCode ->
VisaActivationRemoteState_Json(
VisaActivationRemoteState_Type.WaitingPinCode,
activationOrderInfo = value.activationOrderInfo,
awaitingPinCodeStatus = when (value.status) {
VisaActivationRemoteState.AwaitingPinCode.Status.WaitingForPinCode ->
AwaitingPinCode.Status.WaitingForPinCode ->
AwaitingPinCodeStatus_Type.WaitingForPinCode
VisaActivationRemoteState.AwaitingPinCode.Status.InProgress ->
AwaitingPinCode.Status.InProgress ->
AwaitingPinCodeStatus_Type.InProgress
VisaActivationRemoteState.AwaitingPinCode.Status.WasError ->
AwaitingPinCode.Status.WasError ->
AwaitingPinCodeStatus_Type.WasError
},
)
is VisaActivationRemoteState.WaitingForActivationFinishing ->
is WaitingForActivationFinishing ->
VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.WaitingForActivationFinishing)
is VisaActivationRemoteState.Activated -> VisaActivationRemoteState_Json(
is Activated -> VisaActivationRemoteState_Json(
VisaActivationRemoteState_Type.Activated,
)
is VisaActivationRemoteState.BlockedForActivation ->
is BlockedForActivation ->
VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.BlockedForActivation)
is Failed -> VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.Failed)
}
}
}

View file

@ -12,8 +12,15 @@ data class VisaAuthTokens(
) {
@Serializable
@JvmInline
value class RefreshToken(val value: String)
@JsonClass(generateAdapter = true)
data class RefreshToken(
@Json(name = "value") val value: String,
@Json(name = "authType") val authType: Type,
) {
enum class Type {
CardId, CardWallet,
}
}
}
fun VisaAuthTokens.getAuthHeader(): String {

View file

@ -1,3 +1,12 @@
package com.tangem.domain.visa.model
typealias VisaCardWalletDataToSignRequest = VisaActivationOrderInfo
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = true)
data class VisaCardWalletDataToSignRequest(
@Json(name = "activationOrderInfo") val activationOrderInfo: VisaActivationOrderInfo,
@Json(name = "cardWalletAddress") val cardWalletAddress: String,
)

View file

@ -9,4 +9,5 @@ import kotlinx.serialization.Serializable
data class VisaCustomerWalletDataToSignRequest(
@Json(name = "orderId") val orderId: String,
@Json(name = "card_wallet_address") val cardWalletAddress: String,
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
)

View file

@ -8,10 +8,9 @@ data class VisaDataToSignByCardWallet(
val hashToSign: String,
)
fun VisaDataToSignByCardWallet.sign(cardWalletAddress: String, rootOTP: String, otpCounter: Int, signature: String) =
fun VisaDataToSignByCardWallet.sign(rootOTP: String, otpCounter: Int, signature: String) =
VisaSignedActivationDataByCardWallet(
dataToSign = this,
cardWalletAddress = cardWalletAddress,
rootOTP = rootOTP,
otpCounter = otpCounter,
signature = signature,

View file

@ -2,7 +2,6 @@ package com.tangem.domain.visa.model
class VisaSignedActivationDataByCardWallet(
val dataToSign: VisaDataToSignByCardWallet,
val cardWalletAddress: String,
val rootOTP: String,
val otpCounter: Int,
val signature: String,

View file

@ -1,4 +1,5 @@
@file:Suppress("MaximumLineLength")
package com.tangem.domain.visa
import android.util.Base64
@ -14,14 +15,12 @@ import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
private const val KEY_SIZE = 256
class SetVisaPinCodeUseCase(
private val visaActivationRepositoryFactory: VisaActivationRepository.Factory,
) {
class SetVisaPinCodeUseCase(private val visaActivationRepositoryFactory: VisaActivationRepository.Factory) {
@Suppress("MagicNumber")
suspend operator fun invoke(
visaCardId: VisaCardId,
activationOrderId: String,
@ -29,19 +28,17 @@ class SetVisaPinCodeUseCase(
): Either<Throwable, Unit> = Either.catch {
val visaActivationRepository = visaActivationRepositoryFactory.create(visaCardId)
val rsaPublicKey = visaActivationRepository.getPinCodeRsaEncryptionPublicKey()
val formattedPin = "24$pinCode${"f".repeat(n = 8)}FF"
val sessionKey = generateSessionKey()
val sessionId = getSessionId(rsaPublicKey, sessionKey)
val secureRandom = SecureRandom()
val iv = ByteArray(size = 16)
secureRandom.nextBytes(iv)
val iv = generateIV()
val ivParameterSpec = IvParameterSpec(iv)
val ivPayload = Base64.encodeToString(iv, Base64.NO_WRAP)
val aesKey = SecretKeySpec(sessionKey.encoded, 0, sessionKey.encoded.size, "AES")
val aesCipher = Cipher.getInstance("AES/CBC/PKCS5PADDING")
aesCipher.init(Cipher.ENCRYPT_MODE, aesKey, ivParameterSpec)
val encryptedPin = Base64.encodeToString(aesCipher.doFinal(pinCode.toByteArray()), Base64.NO_WRAP)
val aesCipher = Cipher.getInstance("AES/GCM/NoPadding")
aesCipher.init(Cipher.ENCRYPT_MODE, sessionKey, ivParameterSpec)
val encryptedPin = Base64.encodeToString(aesCipher.doFinal(formattedPin.encodeToByteArray()), Base64.NO_WRAP)
visaActivationRepository.sendPinCode(
VisaEncryptedPinCode(
@ -59,6 +56,12 @@ class SetVisaPinCodeUseCase(
return generator.generateKey()
}
private fun generateIV(): ByteArray {
val iv = ByteArray(size = 16)
SecureRandom().nextBytes(iv)
return iv
}
private fun getPublicKey(rsaPublicKey: String): PublicKey {
val keyBytes = Base64.decode(rsaPublicKey, Base64.NO_WRAP)
val spec = X509EncodedKeySpec(keyBytes)
@ -67,8 +70,9 @@ class SetVisaPinCodeUseCase(
private fun getSessionId(rsaPublicKey: String, sessionKey: Key): String {
val publicKey = getPublicKey(rsaPublicKey)
val cipher = Cipher.getInstance("RSA")
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding")
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
return Base64.encodeToString(cipher.doFinal(sessionKey.encoded), Base64.NO_WRAP)
val base64SessionKey = Base64.encodeToString(sessionKey.encoded, Base64.NO_WRAP)
return Base64.encodeToString(cipher.doFinal(base64SessionKey.encodeToByteArray()), Base64.NO_WRAP)
}
}

View file

@ -6,8 +6,6 @@ interface VisaActivationRepository {
suspend fun getActivationRemoteState(): VisaActivationRemoteState
suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState
suspend fun getCardWalletAcceptanceData(request: VisaCardWalletDataToSignRequest): VisaDataToSignByCardWallet
suspend fun getCustomerWalletAcceptanceData(

View file

@ -167,7 +167,7 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor(
params = OnboardingVisaOtherWalletComponent.Params(
childParams = childParams,
onDone = {
model.stackNavigation.pushNew(
model.stackNavigation.replaceAll(
OnboardingVisaRoute.PinCode(activationOrderInfo = it, pinCodeValidationError = false),
)
},
@ -183,7 +183,7 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor(
params = OnboardingVisaPinCodeComponent.Params(
childParams = childParams,
onDone = {
model.stackNavigation.pushNew(
model.stackNavigation.replaceAll(
OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.PinCode),
)
},
@ -199,7 +199,7 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor(
params = OnboardingVisaApproveComponent.Params(
childParams = childParams,
onDone = {
model.stackNavigation.pushNew(
model.stackNavigation.replaceAll(
OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.Approve),
)
},

View file

@ -20,7 +20,6 @@ import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.OnboardingVisaAccessCodeComponent
import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.ui.state.OnboardingVisaAccessCodeUM
import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.ONBOARDING_SOURCE
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.features.onboarding.v2.visa.impl.common.ActivationReadyEvent
@ -55,9 +54,12 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
cardPublicKey = params.scanResponse.card.cardPublicKey.toHexString(),
),
)
private val activationStatus =
params.scanResponse.visaCardActivationStatus as? VisaCardActivationStatus.NotStartedActivation
?: error("Visa activation status is not set or incorrect for this step")
private val activationInput = when (val status = params.scanResponse.visaCardActivationStatus) {
is VisaCardActivationStatus.NotStartedActivation -> status.activationInput
is VisaCardActivationStatus.ActivationStarted -> status.activationInput
else -> error("Visa activation status is not set or incorrect for this step")
}
private val _uiState = MutableStateFlow(getInitialState())
@ -153,8 +155,8 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
modelScope.launch {
val challengeToSign = runCatching {
visaAuthRepository.getCardAuthChallenge(
cardId = activationStatus.activationInput.cardId,
cardPublicKey = activationStatus.activationInput.cardPublicKey,
cardId = activationInput.cardId,
cardPublicKey = activationInput.cardPublicKey,
)
}.getOrElse {
loading(false)
@ -167,7 +169,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
accessCode = accessCode,
authorizationChallenge = challengeToSign,
),
activationInput = activationStatus.activationInput,
activationInput = activationInput,
)
val resultData = when (result) {
@ -175,7 +177,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
loading(false)
uiMessageSender.showErrorDialog(result.error.universalError)
analyticsEventsHandler.send(
VisaAnalyticsEvent.Errors(result.error.code.toString(), ONBOARDING_SOURCE),
VisaAnalyticsEvent.ErrorOnboarding(result.error.universalError),
)
return@launch
}
@ -190,14 +192,16 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
return@launch
}
val targetAddress = result.data.signedActivationData.dataToSign.request.customerWalletAddress
val targetAddress =
result.data.signedActivationData.dataToSign.request.activationOrderInfo.customerWalletAddress
modelScope.launch {
onDone.emit(
ActivationReadyEvent(
customerWalletDataToSignRequest = VisaCustomerWalletDataToSignRequest(
orderId = result.data.signedActivationData.dataToSign.request.orderId,
cardWalletAddress = result.data.signedActivationData.cardWalletAddress,
orderId = result.data.signedActivationData.dataToSign.request.activationOrderInfo.orderId,
cardWalletAddress = result.data.signedActivationData.dataToSign.request.cardWalletAddress,
customerWalletAddress = targetAddress,
),
newScanResponse = params.scanResponse.copy(
card = result.data.newCardDTO,

View file

@ -16,7 +16,6 @@ import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.features.onboarding.v2.visa.impl.child.approve.OnboardingVisaApproveComponent
import com.tangem.features.onboarding.v2.visa.impl.child.approve.ui.state.OnboardingVisaApproveUM
import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.ONBOARDING_SOURCE
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.sdk.api.TangemSdkManager
@ -88,9 +87,7 @@ internal class OnboardingVisaApproveModel @Inject constructor(
is CompletionResult.Failure -> {
loading(false)
uiMessageSender.showErrorDialog(result.error.universalError)
analyticsEventHandler.send(
VisaAnalyticsEvent.Errors(result.error.code.toString(), ONBOARDING_SOURCE),
)
analyticsEventHandler.send(VisaAnalyticsEvent.ErrorOnboarding(result.error.universalError))
return@launch
}
is CompletionResult.Success -> result.data

View file

@ -22,6 +22,7 @@ import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Config
import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Params
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.features.onboarding.v2.visa.impl.route.OnboardingVisaRoute
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
@ -57,10 +58,14 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
init {
analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ActivationInProgressScreen)
runShortPolling()
}
private fun runShortPolling() {
modelScope.launch {
while (true) {
val result = runCatching {
visaActivationRepository.getActivationRemoteStateLongPoll()
visaActivationRepository.getActivationRemoteState()
}.getOrNull() ?: continue
when (result) {
@ -68,6 +73,16 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
VisaActivationRemoteState.BlockedForActivation,
-> {
uiMessageSender.showErrorDialog(VisaActivationError.InconsistentRemoteState)
analyticsEventHandler.send(
VisaAnalyticsEvent.ErrorOnboarding(VisaActivationError.InconsistentRemoteState),
)
return@launch
}
VisaActivationRemoteState.Failed -> {
uiMessageSender.showErrorDialog(VisaActivationError.FailedRemoteState)
analyticsEventHandler.send(
VisaAnalyticsEvent.ErrorOnboarding(VisaActivationError.FailedRemoteState),
)
return@launch
}
is VisaActivationRemoteState.CustomerWalletSignatureRequired,
@ -86,14 +101,14 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
}
}
delay(timeMillis = 1000)
delay(timeMillis = 2000)
}
}
}
private suspend inline fun navigateToPinCodeIfNeeded(
remoteState: VisaActivationRemoteState.AwaitingPinCode,
returnBlock: () -> Unit,
complete: () -> Unit,
) {
when (remoteState.status) {
VisaActivationRemoteState.AwaitingPinCode.Status.WaitingForPinCode -> {
@ -105,7 +120,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
),
),
)
returnBlock()
complete()
}
VisaActivationRemoteState.AwaitingPinCode.Status.WasError -> {
onDone.emit(
@ -116,10 +131,10 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
),
),
)
returnBlock()
complete()
}
VisaActivationRemoteState.AwaitingPinCode.Status.InProgress -> {
/** waiting for new state */
/** waiting for the new state */
}
}
}

View file

@ -51,7 +51,7 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor(
modelScope.launch {
while (true) {
val result = runCatching {
visaActivationRepository.getActivationRemoteStateLongPoll()
visaActivationRepository.getActivationRemoteState()
}.getOrNull()
if (result is VisaActivationRemoteState.AwaitingPinCode) {
@ -59,7 +59,7 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor(
break
}
delay(timeMillis = 1000)
delay(timeMillis = 2000)
}
}
}

View file

@ -6,15 +6,12 @@ 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.ui.extensions.resourceReference
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.core.ui.utils.showErrorDialog
import com.tangem.domain.visa.SetVisaPinCodeUseCase
import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.visa.error.VisaAuthorizationAPIError
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.visa.impl.child.pincode.OnboardingVisaPinCodeComponent
import com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui.state.OnboardingVisaPinCodeUM
@ -25,7 +22,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@Stable
@ -34,12 +30,9 @@ import javax.inject.Inject
internal class OnboardingVisaPinCodeModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val userWalletsListManager: UserWalletsListManager,
private val authTokenStorage: VisaAuthTokenStorage,
private val otpStorage: VisaAuthTokenStorage,
private val setVisaPinCodeUseCase: SetVisaPinCodeUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val uiMessageSender: UiMessageSender,
) : Model() {
private val params = paramsContainer.require<OnboardingVisaPinCodeComponent.Config>()
@ -102,11 +95,11 @@ internal class OnboardingVisaPinCodeModel @Inject constructor(
activationOrderId = params.activationOrderInfo.orderId,
).onLeft {
loading(false)
uiMessageSender.showErrorDialog(VisaAuthorizationAPIError)
return@launch
}
saveWallet()
onDone.emit(Unit)
loading(false)
}
}
@ -114,26 +107,4 @@ internal class OnboardingVisaPinCodeModel @Inject constructor(
private fun loading(state: Boolean) {
_uiState.update { it.copy(submitButtonLoading = state) }
}
private suspend fun saveWallet() {
val userWallet = createUserWallet(params.scanResponse)
userWalletsListManager.save(userWallet)
authTokenStorage.remove(params.scanResponse.card.cardId)
otpStorage.remove(params.scanResponse.card.cardId)
onDone.emit(Unit)
}
private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet = withContext(dispatchers.io) {
val newActivationStatus = VisaCardActivationStatus.Activated(
visaAuthTokens = authTokenStorage.get(scanResponse.card.cardId)
?: error("Impossible state. Wrong feature implementation"),
)
requireNotNull(
value = userWalletBuilderFactory.create(
scanResponse = scanResponse.copy(visaCardActivationStatus = newActivationStatus),
).build(),
lazyMessage = { "User wallet not created" },
)
}
}

View file

@ -22,10 +22,11 @@ internal object PinCodeValidation {
}
private fun validateNoRepeatedDigits(pinCode: String): Boolean {
return pinCode.toSet().size == PIN_CODE_LENGTH
return pinCode.toSet().size > 1
}
private fun validateNoConsecutiveDigits(pinCode: String): Boolean {
return pinCode.windowed(2).all { it[0] + 1 == it[1] }.not()
return pinCode.zipWithNext().any { it.second != it.first + 1 } &&
pinCode.zipWithNext().any { it.second != it.first - 1 }
}
}

View file

@ -15,7 +15,6 @@ import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.features.onboarding.v2.visa.impl.child.welcome.OnboardingVisaWelcomeComponent.Config
import com.tangem.features.onboarding.v2.visa.impl.child.welcome.OnboardingVisaWelcomeComponent.DoneEvent
import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.ONBOARDING_SOURCE
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.features.onboarding.v2.visa.impl.child.welcome.ui.state.OnboardingVisaWelcomeUM
@ -96,9 +95,7 @@ internal class OnboardingVisaWelcomeModel @Inject constructor(
is CompletionResult.Failure -> {
loading(false)
uiMessageSender.showErrorDialog(result.error.universalError)
analyticsEventsHandler.send(
VisaAnalyticsEvent.Errors(result.error.code.toString(), ONBOARDING_SOURCE),
)
analyticsEventsHandler.send(VisaAnalyticsEvent.ErrorOnboarding(result.error.universalError))
return@launch
}
is CompletionResult.Success -> result.data
@ -112,15 +109,17 @@ internal class OnboardingVisaWelcomeModel @Inject constructor(
return@launch
}
val targetAddress = result.data.signedActivationData.dataToSign.request.customerWalletAddress
val request = result.data.signedActivationData.dataToSign.request
val targetAddress = request.activationOrderInfo.customerWalletAddress
modelScope.launch {
onDone.emit(
DoneEvent.WelcomeBackDone(
ActivationReadyEvent(
customerWalletDataToSignRequest = VisaCustomerWalletDataToSignRequest(
orderId = result.data.signedActivationData.dataToSign.request.orderId,
cardWalletAddress = result.data.signedActivationData.cardWalletAddress,
orderId = request.activationOrderInfo.orderId,
cardWalletAddress = request.cardWalletAddress,
customerWalletAddress = targetAddress,
),
newScanResponse = params.scanResponse.copy(
card = result.data.newCardDTO,

View file

@ -1,6 +1,7 @@
package com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.error.UniversalError
internal const val ONBOARDING_SOURCE = "Onboarding"
internal const val MAIN_SOURCE = "Main"
@ -10,14 +11,11 @@ internal sealed class VisaAnalyticsEvent(
params: Map<String, String> = mapOf(),
) : AnalyticsEvent("Visa", event, params) {
data class Errors(
val errorCode: String,
val source: String,
) : VisaAnalyticsEvent(
data class ErrorOnboarding(val error: UniversalError) : VisaAnalyticsEvent(
event = "Errors",
params = mapOf(
"Error Code" to errorCode,
"Source" to source,
"Error Code" to error.errorCode.toString(),
"Source" to ONBOARDING_SOURCE,
),
)
}

View file

@ -1,10 +1,10 @@
package com.tangem.features.onboarding.v2.visa.impl.model
import androidx.compose.runtime.Stable
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.pushNew
import com.arkivanov.decompose.router.stack.replaceAll
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -12,6 +12,7 @@ import com.tangem.domain.common.visa.VisaUtilities
import com.tangem.domain.common.visa.VisaWalletPublicKeyUtility
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.visa.model.VisaCardWalletDataToSignRequest
import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onboarding.v2.visa.api.OnboardingVisaComponent
@ -83,14 +84,13 @@ internal class OnboardingVisaModel @Inject constructor(
)
}
@OptIn(ExperimentalDecomposeApi::class)
fun navigateFromInProgress(event: OnboardingVisaInProgressComponent.Params.DoneEvent) {
when (event) {
OnboardingVisaInProgressComponent.Params.DoneEvent.Activated -> {
modelScope.launch { onDone.emit(Unit) }
}
is OnboardingVisaInProgressComponent.Params.DoneEvent.NavigateTo -> {
stackNavigation.pushNew(event.route)
stackNavigation.replaceAll(event.route)
}
}
}
@ -145,10 +145,17 @@ internal class OnboardingVisaModel @Inject constructor(
is VisaCardActivationStatus.ActivationStarted -> {
when (val remoteState = activationStatus.remoteState) {
is VisaActivationRemoteState.CardWalletSignatureRequired -> {
OnboardingVisaRoute.WelcomeBack(
activationInput = activationStatus.activationInput,
dataToSignByCardWalletRequest = remoteState.request,
)
if (activationStatus.activationInput.isAccessCodeSet) {
OnboardingVisaRoute.WelcomeBack(
activationInput = activationStatus.activationInput,
dataToSignByCardWalletRequest = VisaCardWalletDataToSignRequest(
activationOrderInfo = remoteState.activationOrderInfo,
cardWalletAddress = activationStatus.cardWalletAddress,
),
)
} else {
OnboardingVisaRoute.AccessCode
}
}
is VisaActivationRemoteState.CustomerWalletSignatureRequired -> {
remoteState.getRoute(activationStatus)
@ -167,6 +174,7 @@ internal class OnboardingVisaModel @Inject constructor(
}
VisaActivationRemoteState.Activated,
VisaActivationRemoteState.BlockedForActivation,
VisaActivationRemoteState.Failed,
-> error("Activation status is not correct for onboarding flow")
}
}
@ -182,6 +190,7 @@ internal class OnboardingVisaModel @Inject constructor(
val request = VisaCustomerWalletDataToSignRequest(
orderId = this.activationOrderInfo.orderId,
cardWalletAddress = activationStatus.cardWalletAddress,
customerWalletAddress = this.activationOrderInfo.customerWalletAddress,
)
val preparationDataForApprove = PreparationDataForApprove(
customerWalletAddress = this.activationOrderInfo.customerWalletAddress,