Updated on 2026-08-14

This commit is contained in:
Tangem 2025-02-11 13:35:31 +03:00
parent 19fb8f2bcd
commit ecdb72e55c
58 changed files with 1082 additions and 346 deletions

View file

@ -1,8 +1,11 @@
package com.tangem.tap.data
import android.content.Context
import com.tangem.common.extensions.toByteArray
import com.tangem.common.extensions.toInt
import com.tangem.common.services.secure.SecureStorage
import com.tangem.datasource.local.visa.VisaOTPStorage
import com.tangem.datasource.local.visa.VisaOtpData
import com.tangem.sdk.storage.AndroidSecureStorage
import com.tangem.sdk.storage.createEncryptedSharedPreferences
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -24,19 +27,27 @@ class DefaultVisaOTPStorage @Inject constructor(
),
)
override suspend fun saveOTP(cardId: String, otp: ByteArray) = withContext(dispatcherProvider.io) {
secureStorage.store(otp, VISA_OTP_KEY_PREFIX + cardId)
override suspend fun saveOTP(cardId: String, data: VisaOtpData) = withContext(dispatcherProvider.io) {
secureStorage.store(data.rootOTP, VISA_ROOT_OTP_KEY_PREFIX + cardId)
secureStorage.store(data.counter.toByteArray(), VISA_OTP_COUNTER_KEY_PREFIX + cardId)
}
override suspend fun getOTP(cardId: String): ByteArray? = withContext(dispatcherProvider.io) {
secureStorage.get(VISA_OTP_KEY_PREFIX + cardId)
override suspend fun getOTP(cardId: String): VisaOtpData? = withContext(dispatcherProvider.io) {
val rootOTP = secureStorage.get(VISA_ROOT_OTP_KEY_PREFIX + cardId) ?: return@withContext null
val counter = secureStorage.get(VISA_OTP_COUNTER_KEY_PREFIX + cardId)?.toInt() ?: return@withContext null
VisaOtpData(
rootOTP = rootOTP,
counter = counter,
)
}
override suspend fun removeOTP(cardId: String) = withContext(dispatcherProvider.io) {
secureStorage.delete(VISA_OTP_KEY_PREFIX + cardId)
secureStorage.delete(VISA_ROOT_OTP_KEY_PREFIX + cardId)
secureStorage.delete(VISA_OTP_COUNTER_KEY_PREFIX + cardId)
}
private companion object {
const val VISA_OTP_KEY_PREFIX = "visa_otp_"
const val VISA_ROOT_OTP_KEY_PREFIX = "visa_root_otp_"
const val VISA_OTP_COUNTER_KEY_PREFIX = "visa_otp_counter_"
}
}

View file

@ -25,8 +25,8 @@ import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.visa.model.VisaActivationInput
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.operations.ScanTask
@ -35,12 +35,12 @@ import com.tangem.operations.derivation.DeriveMultipleWalletPublicKeysTask
import com.tangem.operations.derivation.DeriveWalletPublicKeyTask
import com.tangem.operations.pins.SetUserCodeCommand
import com.tangem.operations.preflightread.PreflightReadFilter
import com.tangem.operations.sign.SignHashResponse
import com.tangem.operations.usersetttings.SetUserCodeRecoveryAllowedTask
import com.tangem.operations.wallet.CreateWalletResponse
import com.tangem.sdk.api.CreateProductWalletTaskResponse
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.sdk.api.visa.VisaCardActivationResponse
import com.tangem.sdk.api.visa.VisaCardActivationTaskMode
import com.tangem.tap.derivationsFinder
import com.tangem.tap.domain.tasks.product.CreateProductWalletTask
import com.tangem.tap.domain.tasks.product.ResetBackupCardTask
@ -480,15 +480,13 @@ internal class DefaultTangemSdkManager(
// region Visa-specific
override suspend fun activateVisaCard(
accessCode: String,
challengeToSign: VisaAuthChallenge.Card?,
mode: VisaCardActivationTaskMode,
activationInput: VisaActivationInput,
): CompletionResult<VisaCardActivationResponse> {
return coroutineScope {
runTaskAsyncReturnOnMain(
runnable = visaCardActivationTaskFactory.create(
accessCode = accessCode,
challengeToSign = challengeToSign,
mode = mode,
activationInput = activationInput,
coroutineScope = this,
),
@ -499,7 +497,7 @@ internal class DefaultTangemSdkManager(
override suspend fun visaCustomerWalletApprove(
visaDataForApprove: VisaDataForApprove,
): CompletionResult<SignHashResponse> {
): CompletionResult<VisaSignedDataByCustomerWallet> {
return runTaskAsyncReturnOnMain(
runnable = VisaCustomerWalletApproveTask(
visaDataForApprove = visaDataForApprove,

View file

@ -18,16 +18,16 @@ import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.visa.model.VisaActivationInput
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.operations.derivation.DerivationTaskResponse
import com.tangem.operations.preflightread.PreflightReadFilter
import com.tangem.operations.sign.SignHashResponse
import com.tangem.operations.wallet.CreateWalletResponse
import com.tangem.sdk.api.CreateProductWalletTaskResponse
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.sdk.api.visa.VisaCardActivationResponse
import com.tangem.sdk.api.visa.VisaCardActivationTaskMode
import com.tangem.tap.domain.sdk.mocks.MockProvider
@Suppress("TooManyFunctions")
@ -203,8 +203,7 @@ class MockTangemSdkManager(
// region Visa-specific
override suspend fun activateVisaCard(
accessCode: String,
challengeToSign: VisaAuthChallenge.Card?,
mode: VisaCardActivationTaskMode,
activationInput: VisaActivationInput,
): CompletionResult<VisaCardActivationResponse> {
error("Not implemented")
@ -212,7 +211,7 @@ class MockTangemSdkManager(
override suspend fun visaCustomerWalletApprove(
visaDataForApprove: VisaDataForApprove,
): CompletionResult<SignHashResponse> {
): CompletionResult<VisaSignedDataByCustomerWallet> {
error("Not implemented")
}

View file

@ -1,5 +1,9 @@
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.common.CompletionResult
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
@ -12,8 +16,10 @@ import com.tangem.common.timemeasure.RealtimeMonotonicTimeSource
import com.tangem.crypto.CryptoUtils
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.datasource.local.visa.VisaOTPStorage
import com.tangem.datasource.local.visa.VisaOtpData
import com.tangem.datasource.local.visa.hasSavedOTP
import com.tangem.domain.common.visa.VisaUtilities
import com.tangem.domain.common.visa.VisaWalletPublicKeyUtility
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaActivationRepository
@ -25,19 +31,18 @@ import com.tangem.operations.sign.SignHashCommand
import com.tangem.operations.sign.SignHashResponse
import com.tangem.operations.wallet.CreateWalletTask
import com.tangem.sdk.api.visa.VisaCardActivationResponse
import com.tangem.sdk.api.visa.VisaCardActivationTaskMode
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.*
import timber.log.Timber
import kotlin.coroutines.resume
import kotlin.jvm.Throws
import kotlin.time.measureTimedValue
@Suppress("LongParameterList")
class VisaCardActivationTask @AssistedInject constructor(
@Assisted private val accessCode: String,
@Assisted private val challengeToSign: VisaAuthChallenge.Card?,
@Assisted private val mode: VisaCardActivationTaskMode,
@Assisted private val activationInput: VisaActivationInput,
@Assisted private val coroutineScope: CoroutineScope,
private val otpStorage: VisaOTPStorage,
@ -65,7 +70,12 @@ class VisaCardActivationTask @AssistedInject constructor(
return CompletionResult.Failure(TangemSdkError.Underlying(VisaActivationError.WrongCard.message))
}
val visaActivationRepository = visaActivationRepositoryFactory.create(card.cardId)
val visaActivationRepository = visaActivationRepositoryFactory.create(
VisaCardId(
cardId = card.cardId,
cardPublicKey = card.cardPublicKey.toHexString(),
),
)
val context = SessionContext(
visaActivationRepository = visaActivationRepository,
@ -74,15 +84,13 @@ class VisaCardActivationTask @AssistedInject constructor(
)
val timedResult = RealtimeMonotonicTimeSource.measureTimedValue {
if (challengeToSign != null) {
context.signAuthorizationChallenge(challengeToSign)
} else {
val activationOrder = runCatching { visaActivationRepository.getActivationOrderToSign() }
.getOrElse {
return CompletionResult.Failure(TangemSdkError.Underlying(it.message ?: ""))
}
context.signOrder(activationOrder)
when (mode) {
is VisaCardActivationTaskMode.Full -> {
context.signAuthorizationChallenge(mode.authorizationChallenge)
}
is VisaCardActivationTaskMode.SignOnly -> {
context.signData(mode.dataToSignByCardWallet)
}
}
}
Timber.i("VisaCardActivationTask all time: ${timedResult.duration}")
@ -123,45 +131,50 @@ class VisaCardActivationTask @AssistedInject constructor(
signedChallenge: VisaAuthSignedChallenge,
): CompletionResult<VisaCardActivationResponse> {
return coroutineScope {
val activationOrderDeferred = async { getActivationOrderToSign(signedChallenge) }
val otpTaskDeferred = async { createWallet(session) }
val dataToSignDeferred = async { getDataToSign(signedChallenge) }
val otpTaskDeferred = async { createWallet() }
val order = runCatching { activationOrderDeferred.await() }
val dataToSign = dataToSignDeferred.await()
.getOrElse {
otpTaskDeferred.cancel()
return@coroutineScope CompletionResult.Failure(TangemSdkError.Underlying(it.message ?: ""))
return@coroutineScope CompletionResult.Failure(it)
}
otpTaskDeferred.await()
signOrder(order)
signData(dataToSign)
}
}
@Throws(TangemSdkError::class)
private suspend fun SessionContext.getActivationOrderToSign(
private suspend fun SessionContext.getDataToSign(
signedChallenge: VisaAuthSignedChallenge,
): ActivationOrder {
val tokens = runCatching {
visaAuthRepository.getAccessTokens(signedChallenge)
}.getOrElse {
throw TangemSdkError.Underlying(
"Underlying network error: ${it.message ?: ""}",
)
}
): Either<TangemSdkError.Underlying, VisaDataToSignByCardWallet> = either {
catch(
block = {
val tokens = visaAuthRepository.getAccessTokens(signedChallenge)
visaAuthTokenStorage.store(cardId, tokens)
visaAuthTokenStorage.store(cardId, tokens)
return visaActivationRepository.getActivationOrderToSign()
val remoteState = visaActivationRepository.getActivationRemoteState()
if (remoteState !is VisaActivationRemoteState.CardWalletSignatureRequired) {
raise(TangemSdkError.Underlying(VisaActivationError.WrongRemoteState.message))
}
visaActivationRepository.getCardWalletAcceptanceData(remoteState.request)
},
catch = {
raise(TangemSdkError.Underlying("Underlying network error: ${it.message ?: ""}"))
},
)
}
private suspend fun SessionContext.createWallet(session: CardSession): CompletionResult<Unit> {
private suspend fun SessionContext.createWallet(): CompletionResult<Unit> {
coroutineScope { ensureActive() }
val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
return if (card.wallets.any { it.curve == VisaUtilities.mandatoryCurve }) {
createOTP(session)
createOTP()
} else {
val createWalletTask = CreateWalletTask(VisaUtilities.mandatoryCurve)
@ -178,7 +191,7 @@ class VisaCardActivationTask @AssistedInject constructor(
when (val result = timedResult.value) {
is CompletionResult.Success -> {
Timber.i("CreateWalletTask success")
createOTP(session)
createOTP()
}
is CompletionResult.Failure -> {
Timber.e("CreateWalletTask failure ${result.error}")
@ -188,46 +201,58 @@ class VisaCardActivationTask @AssistedInject constructor(
}
}
private suspend fun SessionContext.createOTP(session: CardSession): CompletionResult<Unit> {
private suspend fun SessionContext.createOTP(): CompletionResult<Unit> {
coroutineScope { ensureActive() }
return if (otpStorage.hasSavedOTP(cardId)) {
CompletionResult.Success(Unit)
} else {
val otpCommand = GenerateOTPCommand()
val timedResult = RealtimeMonotonicTimeSource.measureTimedValue {
suspendCancellableCoroutine { continuation ->
otpCommand.run(session) { otpResult ->
continuation.resume(otpResult)
}
if (otpStorage.hasSavedOTP(cardId)) {
return CompletionResult.Success(Unit)
}
val otpCommand = GenerateOTPCommand()
val timedResult = RealtimeMonotonicTimeSource.measureTimedValue {
suspendCancellableCoroutine { continuation ->
otpCommand.run(session) { otpResult ->
continuation.resume(otpResult)
}
}
}
Timber.i("GenerateOTPCommand time: ${timedResult.duration}")
Timber.i("GenerateOTPCommand time: ${timedResult.duration}")
when (val result = timedResult.value) {
is CompletionResult.Success -> {
Timber.i("GenerateOTPCommand success")
otpStorage.saveOTP(cardId, result.data.rootOTP)
CompletionResult.Success(Unit)
}
is CompletionResult.Failure -> {
Timber.e("GenerateOTPCommand failure ${result.error}")
CompletionResult.Failure(result.error)
}
return when (val result = timedResult.value) {
is CompletionResult.Success -> {
Timber.i("GenerateOTPCommand success")
otpStorage.saveOTP(
cardId = cardId,
data = VisaOtpData(result.data.rootOTP, result.data.rootOTPCounter),
)
CompletionResult.Success(Unit)
}
is CompletionResult.Failure -> {
Timber.e("GenerateOTPCommand failure ${result.error}")
CompletionResult.Failure(result.error)
}
}
}
private suspend fun SessionContext.signOrder(order: ActivationOrder): CompletionResult<VisaCardActivationResponse> {
private suspend fun SessionContext.signData(
dataToSign: VisaDataToSignByCardWallet,
): CompletionResult<VisaCardActivationResponse> {
val card =
session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val wallet =
card.wallets.firstOrNull { it.curve == VisaUtilities.mandatoryCurve }
?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val derivedPublicKey = wallet.derivedKeys[VisaUtilities.visaDefaultDerivationPath]
?: return CompletionResult.Failure(TangemSdkError.Underlying(VisaActivationError.MissingWallet.message))
val walletAddress = VisaWalletPublicKeyUtility.generateAddressOnVisaCurve(derivedPublicKey.publicKey)
.getOrElse { return CompletionResult.Failure(TangemSdkError.Underlying(it.message)) }
.value
val task = SignHashCommand(
hash = order.hash.hexToBytes(),
hash = dataToSign.hashToSign.hexToBytes(),
walletPublicKey = wallet.publicKey,
derivationPath = VisaUtilities.visaDefaultDerivationPath,
)
@ -245,8 +270,9 @@ class VisaCardActivationTask @AssistedInject constructor(
return when (val result = timedResult.value) {
is CompletionResult.Success -> {
Timber.i("SignHashCommand success")
handleSignedOrder(
activationOrder = order,
handleSignedData(
dataToSign = dataToSign,
walletAddress = walletAddress,
response = result.data,
)
}
@ -257,26 +283,29 @@ class VisaCardActivationTask @AssistedInject constructor(
}
}
private suspend fun SessionContext.handleSignedOrder(
activationOrder: ActivationOrder,
private suspend fun SessionContext.handleSignedData(
dataToSign: VisaDataToSignByCardWallet,
walletAddress: String,
response: SignHashResponse,
): CompletionResult<VisaCardActivationResponse> {
val signedOrder = SignedActivationOrder(
activationOrder = activationOrder,
val otp = otpStorage.getOTP(cardId) ?: run {
createOTP()
otpStorage.getOTP(cardId) ?: return CompletionResult.Failure(TangemSdkError.Underlying("OTP not found"))
}
val signedActivationData = dataToSign.sign(
cardWalletAddress = walletAddress,
rootOTP = otp.rootOTP.toHexString(),
otpCounter = otp.counter,
signature = response.signature.toHexString(),
)
val otp = otpStorage.getOTP(cardId) ?: return CompletionResult.Failure(
TangemSdkError.Underlying(VisaActivationError.MissingRootOTP.message),
)
return setupAccessCode().map {
val card =
session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val card = session.environment.card
?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
VisaCardActivationResponse(
signedActivationOrder = signedOrder,
rootOTP = VisaRootOTP(otp.toHexString()),
signedActivationData = signedActivationData,
newCardDTO = CardDTO(card),
)
}
@ -285,13 +314,13 @@ class VisaCardActivationTask @AssistedInject constructor(
private suspend fun SessionContext.setupAccessCode(): CompletionResult<Unit> {
val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
if (card.isAccessCodeSet) {
if (card.isAccessCodeSet || mode !is VisaCardActivationTaskMode.Full) {
return CompletionResult.Success(Unit)
}
Timber.i("Setting access code")
val task = SetUserCodeCommand.changeAccessCode(accessCode)
val task = SetUserCodeCommand.changeAccessCode(mode.accessCode)
val timedResult = RealtimeMonotonicTimeSource.measureTimedValue {
suspendCancellableCoroutine { continuation ->
@ -318,8 +347,7 @@ class VisaCardActivationTask @AssistedInject constructor(
@AssistedFactory
interface Factory {
fun create(
accessCode: String,
challengeToSign: VisaAuthChallenge.Card?,
mode: VisaCardActivationTaskMode,
activationInput: VisaActivationInput,
coroutineScope: CoroutineScope,
): VisaCardActivationTask

View file

@ -9,6 +9,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.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.common.util.derivationStyleProvider
@ -18,16 +19,17 @@ import com.tangem.domain.common.visa.VisaWalletPublicKeyUtility.findKeyWithoutDe
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.visa.model.VisaActivationError
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
import com.tangem.domain.visa.model.sign
import com.tangem.operations.ScanTask
import com.tangem.operations.derivation.DeriveWalletPublicKeyTask
import com.tangem.operations.sign.SignHashCommand
import com.tangem.operations.sign.SignHashResponse
class VisaCustomerWalletApproveTask(
private val visaDataForApprove: VisaDataForApprove,
) : CardSessionRunnable<SignHashResponse> {
) : CardSessionRunnable<VisaSignedDataByCustomerWallet> {
override fun run(session: CardSession, callback: CompletionCallback<SignHashResponse>) {
override fun run(session: CardSession, callback: CompletionCallback<VisaSignedDataByCustomerWallet>) {
val card = session.environment.card ?: run {
callback(CompletionResult.Failure(TangemSdkError.MissingPreflightRead()))
return
@ -54,7 +56,11 @@ class VisaCustomerWalletApproveTask(
}
}
private fun proceedApprove(card: Card, session: CardSession, callback: CompletionCallback<SignHashResponse>) {
private fun proceedApprove(
card: Card,
session: CardSession,
callback: CompletionCallback<VisaSignedDataByCustomerWallet>,
) {
val cardDTO = CardDTO(card)
val derivationStyle = cardDTO.derivationStyleProvider.getDerivationStyle() ?: run {
@ -108,7 +114,7 @@ class VisaCustomerWalletApproveTask(
extendedPublicKey: ExtendedPublicKey,
derivationPath: DerivationPath,
session: CardSession,
callback: CompletionCallback<SignHashResponse>,
callback: CompletionCallback<VisaSignedDataByCustomerWallet>,
) {
val validationResult = VisaWalletPublicKeyUtility.validateExtendedPublicKey(
targetAddress = visaDataForApprove.targetAddress,
@ -131,7 +137,7 @@ class VisaCustomerWalletApproveTask(
private fun proceedApproveWithLegacyCard(
card: Card,
session: CardSession,
callback: CompletionCallback<SignHashResponse>,
callback: CompletionCallback<VisaSignedDataByCustomerWallet>,
) {
val publicKey = findKeyWithoutDerivation(
targetAddress = visaDataForApprove.targetAddress,
@ -153,10 +159,10 @@ class VisaCustomerWalletApproveTask(
targetWalletPublicKey: ByteArray,
derivationPath: DerivationPath?,
session: CardSession,
callback: CompletionCallback<SignHashResponse>,
callback: CompletionCallback<VisaSignedDataByCustomerWallet>,
) {
val signTask = SignHashCommand(
hash = visaDataForApprove.approveHash.hexToBytes(),
hash = visaDataForApprove.dataToSign.hashToSign.hexToBytes(),
walletPublicKey = targetWalletPublicKey,
derivationPath = derivationPath,
)
@ -165,9 +171,12 @@ class VisaCustomerWalletApproveTask(
when (result) {
is CompletionResult.Success -> {
scanCard(
signHashResponse = result.data,
session = session,
callback = callback,
signedData = visaDataForApprove.dataToSign.sign(
signature = result.data.signature.toHexString(),
customerWalletAddress = visaDataForApprove.targetAddress,
),
)
}
is CompletionResult.Failure -> {
@ -178,15 +187,15 @@ class VisaCustomerWalletApproveTask(
}
private fun scanCard(
signHashResponse: SignHashResponse,
signedData: VisaSignedDataByCustomerWallet,
session: CardSession,
callback: CompletionCallback<SignHashResponse>,
callback: CompletionCallback<VisaSignedDataByCustomerWallet>,
) {
val scanTask = ScanTask()
scanTask.run(session) { result ->
when (result) {
is CompletionResult.Success -> {
callback(CompletionResult.Success(signHashResponse))
callback(CompletionResult.Success(signedData))
}
is CompletionResult.Failure -> {
callback(CompletionResult.Failure(result.error))

View file

@ -8,6 +8,7 @@ import com.tangem.common.json.TangemSdkAdapter
import com.tangem.common.services.secure.SecureStorage
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.models.scan.serialization.*
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.sdk.storage.AndroidSecureStorage
@ -61,8 +62,9 @@ internal object UserWalletsListManagerModule {
.add(TangemSdkAdapter.DateAdapter())
.add(TangemSdkAdapter.DerivationNodeAdapter())
.add(TangemSdkAdapter.FirmwareVersionAdapter()) // For PrimaryCard model
.add(VisaActivationRemoteState.jsonAdapter)
.add(VisaCardActivationStatus.jsonAdapter)
.add(KotlinJsonAdapterFactory())
.addLast(KotlinJsonAdapterFactory())
.build()
val secureStorage = AndroidSecureStorage(

View file

@ -1,5 +1,6 @@
package com.tangem.tap.domain.visa
import arrow.core.getOrElse
import com.tangem.common.CompletionResult
import com.tangem.common.card.CardWallet
import com.tangem.common.core.CardSession
@ -10,6 +11,7 @@ import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.domain.common.visa.VisaUtilities
import com.tangem.domain.common.visa.VisaWalletPublicKeyUtility
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
@ -43,7 +45,12 @@ internal class VisaCardScanHandler @Inject constructor(
return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
}
val visaActivationRepository = visaActivationRepositoryFactory.create(card.cardId)
val visaActivationRepository = visaActivationRepositoryFactory.create(
VisaCardId(
cardId = card.cardId,
cardPublicKey = card.cardPublicKey.toHexString(),
),
)
val context = SessionContext(
visaActivationRepository = visaActivationRepository,
@ -53,7 +60,7 @@ internal class VisaCardScanHandler @Inject constructor(
val wallet = card.wallets.firstOrNull { it.curve == VisaUtilities.mandatoryCurve } ?: run {
val activationInput =
VisaActivationInput(card.cardId, card.cardPublicKey, card.isAccessCodeSet)
VisaActivationInput(card.cardId, card.cardPublicKey.toHexString(), card.isAccessCodeSet)
val activationStatus = VisaCardActivationStatus.NotStartedActivation(activationInput)
return CompletionResult.Success(activationStatus)
}
@ -119,14 +126,21 @@ internal class VisaCardScanHandler @Inject constructor(
)
}
val walletAddress = VisaWalletPublicKeyUtility.generateAddressOnVisaCurve(extendedPublicKey.publicKey)
.getOrElse {
return CompletionResult.Failure(
TangemSdkError.Underlying("Cannot generate address on Visa curve"),
)
}
Timber.i("Requesting challenge for wallet authorization")
val challengeResponse = runCatching {
visaAuthRepository.getCustomerWalletAuthChallenge(
cardId = card.cardId,
walletPublicKey = extendedPublicKey.publicKey.toHexString(),
)
visaAuthRepository.getCardWalletAuthChallenge(cardWalletAddress = walletAddress.value)
}.getOrElse {
return CompletionResult.Failure(TangemSdkError.Underlying(it.message ?: "Unknown error"))
Timber.i(
"Failed to get Access token for Wallet public key authoziation. Authorizing using Card Pub key",
)
return handleCardAuthorization()
}
val signChallengeResult = signChallengeWithWallet(
@ -162,11 +176,6 @@ internal class VisaCardScanHandler @Inject constructor(
return handleCardAuthorization()
}
visaAuthTokenStorage.store(
cardId = cardId,
tokens = authorizationTokensResponse,
)
Timber.i("Authorized using Wallet public key successfully")
return CompletionResult.Success(VisaCardActivationStatus.Activated(authorizationTokensResponse))
@ -240,7 +249,7 @@ internal class VisaCardScanHandler @Inject constructor(
val activationInput = VisaActivationInput(
cardId = card.cardId,
cardPublicKey = card.cardPublicKey,
cardPublicKey = card.cardPublicKey.toHexString(),
isAccessCodeSet = card.isAccessCodeSet,
)

View file

@ -2,6 +2,7 @@ package com.tangem.tap.network.auth
import com.tangem.datasource.api.common.visa.TangemVisaAuthProvider
import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.visa.model.getAuthHeader
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import javax.inject.Inject
@ -13,8 +14,6 @@ internal class DefaultVisaAuthProvider @Inject constructor(
val card = userWalletsListManager.userWalletsSync.firstOrNull { it.cardId == cardId }
val status = card?.scanResponse?.visaCardActivationStatus as? VisaCardActivationStatus.Activated
?: return "Error in the app!"
val accessToken = status.visaAuthTokens.accessToken
return "Bearer $accessToken"
return status.visaAuthTokens.getAuthHeader()
}
}

View file

@ -15,7 +15,7 @@ import retrofit2.converter.moshi.MoshiConverterFactory
object MoshiConverter {
val networkMoshi: Moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.addLast(KotlinJsonAdapterFactory())
.add(BigDecimalAdapter())
.add(TangemSdkAdapter.ByteArrayAdapter())
.build()

View file

@ -1,8 +1,11 @@
package com.tangem.datasource.api.common.config
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.version.AppVersionProvider
internal class TangemVisa : ApiConfig() {
internal class TangemVisa(
private val appVersionProvider: AppVersionProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD
@ -16,5 +19,8 @@ internal class TangemVisa : ApiConfig() {
headers = createHeaders(),
)
private fun createHeaders() = mapOf<String, ProviderSuspend<String>>()
private fun createHeaders() = mapOf(
"version" to ProviderSuspend { appVersionProvider.versionName },
"platform" to ProviderSuspend { "Android" },
)
}

View file

@ -1,8 +1,11 @@
package com.tangem.datasource.api.common.config
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.version.AppVersionProvider
internal class TangemVisaAuth : ApiConfig() {
internal class TangemVisaAuth(
private val appVersionProvider: AppVersionProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.STAGE
@ -16,5 +19,8 @@ internal class TangemVisaAuth : ApiConfig() {
headers = createHeaders(),
)
private fun createHeaders() = mapOf<String, ProviderSuspend<String>>()
private fun createHeaders() = mapOf(
"version" to ProviderSuspend { appVersionProvider.versionName },
"platform" to ProviderSuspend { "Android" },
)
}

View file

@ -1,21 +1,67 @@
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.response.CardActivationRemoteStateResponse
import com.tangem.datasource.api.visa.models.response.CardWalletDataToSignResponse
import com.tangem.datasource.api.visa.models.response.CustomerWalletDataToSignResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Query
import java.util.concurrent.TimeUnit
interface TangemVisaApi {
@GET("activation-status")
@GET("product_instance/activation_status")
suspend fun getRemoteActivationStatus(
@Header("Authorization") authHeader: String,
): CardActivationRemoteStateResponse
@Query("customer_id") customerId: String,
@Query("product_instance_id") productInstanceId: String,
@Query("card_id") cardId: String,
@Query("card_public_key") cardPublicKey: String,
): ApiResponse<CardActivationRemoteStateResponse>
@ReadTimeout(duration = 20, TimeUnit.MINUTES)
@GET("activation-status")
@GET("product_instance/activation_status")
suspend fun getRemoteActivationStatusLongPoll(
@Header("Authorization") authHeader: String,
): CardActivationRemoteStateResponse
@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")
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>
@GET("product_instance/customer_wallet_acceptance")
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>
@POST("product_instance/activation_by_card_wallet")
suspend fun activateByCardWallet(
@Header("Authorization") authHeader: String,
@Body body: ActivationByCardWalletRequest,
): ApiResponse<Unit>
@POST("product_instance/activation_by_customer_wallet")
suspend fun activateByCustomerWallet(
@Header("Authorization") authHeader: String,
@Body body: ActivationByCustomerWalletRequest,
): ApiResponse<Unit>
}

View file

@ -3,45 +3,29 @@ package com.tangem.datasource.api.visa
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.FormUrlEncoded
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Query
interface TangemVisaAuthApi {
@FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("auth/clients/mobile-app-android/nonce-challenge")
@POST("auth/card_wallet")
suspend fun generateNonceByWalletAddress(
@Field("customer_id") customerId: String? = null,
@Field("customer_wallet_address") customerWalletAddress: String,
@Query("card_wallet_address") cardWalletAddress: String,
): GenerateNonceResponse
@FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("auth/clients/mobile-app-android/nonce-challenge")
@POST("auth/card_id")
suspend fun generateNonceByCard(
@Field("card_id") cardId: String,
@Field("card_public_key") cardPublicKey: String,
@Query("card_id") cardId: String,
@Query("card_public_key") cardPublicKey: String,
): GenerateNonceResponse
@FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("auth/protocol/openid-connect/token")
@POST("auth/get_token")
suspend fun getAccessToken(
@Field("client_id") clientId: String = "mobile-app-android",
@Field("grant_type") grantType: String = "password",
@Field("session_id") sessionId: String,
@Field("signature") signature: String,
@Field("salt") salt: String?,
@Query("session_id") sessionId: String,
@Query("signature") signature: String,
@Query("salt") salt: String?,
): JWTResponse
@FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("auth/protocol/openid-connect/token")
suspend fun refreshAccessToken(
@Field("client_id") clientId: String = "mobile-app-android",
@Field("grant_type") grantType: String = "refresh_token",
@Field("refresh_token") refreshToken: String,
): JWTResponse
@POST("auth/refresh_token")
suspend fun refreshAccessToken(@Field("refresh_token") refreshToken: String): JWTResponse
}

View file

@ -0,0 +1,40 @@
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
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,
) {
@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)
data class CardWalletConfirmation(
@Json(name = "challenge") val challenge: String,
@Json(name = "wallet_salt") val walletSalt: String,
@Json(name = "wallet_signature") val walletSignature: String,
@Json(name = "card_salt") val cardSalt: String,
@Json(name = "card_signature") val cardSignature: String,
)
@JsonClass(generateAdapter = true)
data class Otp(
@Json(name = "root_otp") val rootOtp: String,
@Json(name = "counter") val counter: Int,
)
}

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
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,
) {
@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,
)
}

View file

@ -5,5 +5,12 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CardActivationRemoteStateResponse(
@Json(name = "state") val state: String,
)
@Json(name = "activation_status") val status: String,
@Json(name = "activation_order") val activationOrder: ActivationOrder?,
) {
@JsonClass(generateAdapter = true)
data class ActivationOrder(
@Json(name = "id") val id: String,
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
)
}

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 CardWalletDataToSignResponse(
@Json(name = "dataForCardWallet") val dataForCardWallet: Data,
) {
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "hash") val hash: String,
)
}

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 CustomerWalletDataToSignResponse(
@Json(name = "data_for_customer_wallet") val dataForCardWallet: Data,
) {
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "hash") val hash: String,
)
}

View file

@ -42,9 +42,10 @@ internal object ApiConfigsModule {
@Provides
@IntoSet
fun provideTangemAuthVisaConfig(): ApiConfig = TangemVisaAuth()
fun provideTangemAuthVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig =
TangemVisaAuth(appVersionProvider)
@Provides
@IntoSet
fun provideTangemVisaConfig(): ApiConfig = TangemVisa()
fun provideTangemVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemVisa(appVersionProvider)
}

View file

@ -10,6 +10,7 @@ import com.tangem.datasource.api.common.adapter.LocalDateAdapter
import com.tangem.datasource.api.common.adapter.addStakeKitEnumFallbackAdapters
import com.tangem.datasource.local.config.providers.models.ProviderModel
import com.tangem.domain.models.scan.serialization.*
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.VisaCardActivationStatus
import dagger.Module
import dagger.Provides
@ -35,8 +36,9 @@ class MoshiModule {
.add(BigDecimalAdapter())
.add(LocalDateAdapter())
.add(DateTimeAdapter())
.add(VisaActivationRemoteState.jsonAdapter)
.add(VisaCardActivationStatus.jsonAdapter)
.add(KotlinJsonAdapterFactory())
.addLast(KotlinJsonAdapterFactory())
.addStakeKitEnumFallbackAdapters()
.build()
}
@ -59,10 +61,11 @@ class MoshiModule {
val typedAdapters = MoshiJsonConverter.getTangemSdkTypedAdapters()
return Moshi.Builder().apply {
add(VisaActivationRemoteState.jsonAdapter)
add(VisaCardActivationStatus.jsonAdapter)
adapters.forEach { this.add(it) }
typedAdapters.forEach { add(it.key, it.value) }
add(KotlinJsonAdapterFactory())
addLast(KotlinJsonAdapterFactory())
}.build()
}

View file

@ -2,11 +2,16 @@ package com.tangem.datasource.local.visa
interface VisaOTPStorage {
suspend fun saveOTP(cardId: String, otp: ByteArray)
suspend fun saveOTP(cardId: String, data: VisaOtpData)
suspend fun getOTP(cardId: String): ByteArray?
suspend fun getOTP(cardId: String): VisaOtpData?
suspend fun removeOTP(cardId: String)
}
class VisaOtpData(
val rootOTP: ByteArray,
val counter: Int,
)
suspend fun VisaOTPStorage.hasSavedOTP(cardId: String): Boolean = getOTP(cardId) != null

View file

@ -36,8 +36,8 @@ private val API_CONFIGS = setOf(
Express(configManager, expressAuthProvider, appVersionProvider),
TangemTech(appVersionProvider, appAuthProvider),
StakeKit(stakeKitAuthProvider),
TangemVisaAuth(),
TangemVisa(),
TangemVisaAuth(appVersionProvider),
TangemVisa(appVersionProvider),
)
/**
@ -188,6 +188,10 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.STAGE,
baseUrl = "https://api-s.tangem.org/",
headers = mapOf(
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },
),
),
)
}
@ -198,7 +202,10 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://bff.tangem.com/",
headers = mapOf(),
headers = mapOf(
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },
),
),
)
}

View file

@ -1,49 +1,195 @@
package com.tangem.data.visa
import com.tangem.common.extensions.toHexString
import com.tangem.crypto.CryptoUtils
import com.tangem.data.visa.converter.AccessCodeDataConverter
import com.tangem.data.visa.converter.VisaActivationStatusConverter
import com.tangem.datasource.api.common.visa.TangemVisaAuthProvider
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.domain.visa.model.ActivationOrder
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.datasource.api.visa.models.request.ActivationByCardWalletRequest
import com.tangem.datasource.api.visa.models.request.ActivationByCustomerWalletRequest
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.domain.visa.exception.RefreshTokenExpiredException
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.withContext
@Suppress("UnusedPrivateMember")
@Suppress("LongParameterList")
internal class DefaultVisaActivationRepository @AssistedInject constructor(
@Assisted private val cardId: String,
@Assisted private val visaCardId: VisaCardId,
private val visaApi: TangemVisaApi,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val visaActivationStatusConverter: VisaActivationStatusConverter,
private val visaAuthProvider: TangemVisaAuthProvider,
private val visaAuthTokenStorage: VisaAuthTokenStorage,
private val accessCodeDataConverter: AccessCodeDataConverter,
private val visaAuthRepository: VisaAuthRepository,
) : VisaActivationRepository {
override suspend fun getActivationRemoteState(): VisaActivationRemoteState = withContext(dispatcherProvider.io) {
// visaActivationStatusConverter.convert(visaApi.getRemoteActivationStatus(visaAuthProvider.getAuthHeader(cardId)))
VisaActivationRemoteState.CardWalletSignatureRequired // mock
// TODO implement refreshing access token if it's expired
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,
).getOrThrow()
}
visaActivationStatusConverter.convert(result)
}
override suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState =
withContext(dispatcherProvider.io) {
// visaActivationStatusConverter.convert(
// visaApi.getRemoteActivationStatusLongPoll(visaAuthProvider.getAuthHeader(cardId)),
// )
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
VisaActivationRemoteState.WaitingPinCode
visaApi.getRemoteActivationStatusLongPoll(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
cardId = visaCardId.cardId,
cardPublicKey = visaCardId.cardPublicKey,
).getOrThrow()
}
visaActivationStatusConverter.convert(result)
}
override suspend fun getActivationOrderToSign(): ActivationOrder = withContext(dispatcherProvider.io) {
ActivationOrder(CryptoUtils.generateRandomBytes(length = 32).toHexString())
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,
).getOrThrow()
}
VisaDataToSignByCardWallet(
request = request,
hashToSign = result.dataForCardWallet.hash,
)
}
override suspend fun getCustomerWalletAcceptanceData(
request: VisaCustomerWalletDataToSignRequest,
): VisaDataToSignByCustomerWallet = 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.getCustomerWalletAcceptance(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = request.orderId,
cardWalletAddress = request.cardWalletAddress,
).getOrThrow()
}
VisaDataToSignByCustomerWallet(
request = request,
hashToSign = result.dataForCardWallet.hash,
)
}
override suspend fun activateCard(signedData: VisaSignedActivationDataByCardWallet) {
withContext(dispatcherProvider.io) {
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,
),
),
),
)
}
}
}
override suspend fun approveByCustomerWallet(signedData: VisaSignedDataByCustomerWallet) {
withContext(dispatcherProvider.io) {
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,
),
),
),
)
}
}
}
private suspend fun <T : Any> request(requestBlock: suspend () -> T): T {
return runCatching {
requestBlock()
}.getOrElse { responseError ->
if (responseError !is ApiResponseError.HttpException ||
responseError.code != ApiResponseError.HttpException.Code.UNAUTHORIZED
) {
throw responseError
}
val authTokens = visaAuthTokenStorage.get(visaCardId.cardId) ?: error("Auth tokens are not stored")
val newTokens = runCatching {
visaAuthRepository.refreshAccessTokens(authTokens.refreshToken)
}.getOrElse { throw RefreshTokenExpiredException() }
visaAuthTokenStorage.store(visaCardId.cardId, newTokens)
requestBlock()
}
}
@AssistedFactory
interface Factory : VisaActivationRepository.Factory {
override fun create(cardId: String): DefaultVisaActivationRepository
override fun create(cardId: VisaCardId): DefaultVisaActivationRepository
}
}

View file

@ -36,24 +36,22 @@ internal class DefaultVisaAuthRepository @Inject constructor(
)
}
override suspend fun getCustomerWalletAuthChallenge(
cardId: String,
walletPublicKey: String,
): VisaAuthChallenge.Wallet = withContext(dispatchers.io) {
// val response = visaAuthApi.generateNonceByWalletAddress(
// customerId = cardId,
// customerWalletAddress = walletPublicKey,
// )
//
// VisaAuthChallenge.Wallet(
// challenge = response.nonce,
// session = VisaAuthSession(response.sessionId),
// )
VisaAuthChallenge.Wallet(
challenge = CryptoUtils.generateRandomBytes(length = 32).toHexString(),
session = VisaAuthSession("session"),
)
}
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),
// )
VisaAuthChallenge.Wallet(
challenge = CryptoUtils.generateRandomBytes(length = 32).toHexString(),
session = VisaAuthSession("session"),
)
}
override suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): VisaAuthTokens =
withContext(dispatchers.io) {

View file

@ -0,0 +1,50 @@
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.PaymentAccountDeploying
}
override suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState {
return VisaActivationRemoteState.WaitingPinCode
}
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) {}
@AssistedFactory
interface Factory : VisaActivationRepository.Factory {
override fun create(cardId: VisaCardId): MockVisaActivationRepository
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.data.visa.converter
import com.squareup.moshi.Moshi
import com.tangem.data.visa.model.AccessCodeData
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.utils.converter.Converter
import okio.ByteString.Companion.decodeBase64
import javax.inject.Inject
import javax.inject.Singleton
private const val JWT_PAYLOAD_INDEX = 1
@Singleton
internal class AccessCodeDataConverter @Inject constructor(
@NetworkMoshi private val moshi: Moshi,
) : Converter<VisaAuthTokens, AccessCodeData> {
private val adapter = moshi.adapter(AccessCodeData::class.java)
override fun convert(value: VisaAuthTokens): AccessCodeData {
val payloadBase64 = value.accessToken.split(".").getOrNull(JWT_PAYLOAD_INDEX)
val decodedString = payloadBase64?.decodeBase64()?.utf8()
val data = decodedString?.let { adapter.fromJson(it) }
requireNotNull(data) { "Invalid access token" }
return data
}
}

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.DummyVisaRepository
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
@ -36,9 +36,16 @@ internal interface VisaDataBindsModule {
@Singleton
fun bindVisaAuthRepository(repository: DefaultVisaAuthRepository): VisaAuthRepository
// @Binds
// @Singleton
// fun bindVisaActivationRepositoryFactory(
// repository: DefaultVisaActivationRepository.Factory,
// ): VisaActivationRepository.Factory
// Mocked
@Binds
@Singleton
fun bindVisaActivationRepositoryFactory(
repository: DefaultVisaActivationRepository.Factory,
repository: MockVisaActivationRepository.Factory,
): VisaActivationRepository.Factory
}

View file

@ -0,0 +1,10 @@
package com.tangem.data.visa.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class AccessCodeData(
@Json(name = "pid") val productInstanceId: String,
@Json(name = "sub") val customerId: String,
)

View file

@ -4,6 +4,7 @@ import arrow.core.Either
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.common.address.Address
import com.tangem.blockchain.common.address.AddressType
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.models.scan.CardDTO
@ -28,6 +29,21 @@ object VisaWalletPublicKeyUtility {
wallet.publicKey
}
fun generateAddressOnVisaCurve(walletPublicKey: ByteArray): Either<Error, Address> = either {
val addresses = catch(
block = {
VisaUtilities.visaBlockchain.makeAddresses(
walletPublicKey = walletPublicKey,
pairPublicKey = null,
curve = VisaUtilities.mandatoryCurve,
)
},
catch = { raise(Error.FailedToCreateAddress) },
)
addresses.firstOrNull { it.type == AddressType.Default } ?: raise(Error.FailedToCreateAddress)
}
private fun findWalletOnVisaCurve(card: CardDTO): Either<Error, CardDTO.Wallet> = either {
card.wallets.firstOrNull { it.curve == VisaUtilities.mandatoryCurve } ?: raise(Error.MissingWalletOnTargetCurve)
}
@ -40,21 +56,6 @@ object VisaWalletPublicKeyUtility {
}
}
private fun generateAddressOnVisaCurve(walletPublicKey: ByteArray): Either<Error, Address> = either {
val addresses = catch(
block = {
VisaUtilities.visaBlockchain.makeAddresses(
walletPublicKey = walletPublicKey,
pairPublicKey = null,
curve = VisaUtilities.mandatoryCurve,
)
},
catch = { raise(Error.FailedToCreateAddress) },
)
addresses.firstOrNull() ?: raise(Error.FailedToCreateAddress)
}
enum class Error(val message: String) {
AddressNotMatched("ValidationError: Address not matched"),
FailedToCreateAddress("ValidationError: Failed to create address"),

View file

@ -4,6 +4,7 @@ import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.tangem.common.json.TangemSdkAdapter
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.VisaCardActivationStatus
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
@ -23,8 +24,9 @@ internal object ScanResponseAsStringSerializer : KSerializer<ScanResponse> {
.add(TangemSdkAdapter.DateAdapter())
.add(TangemSdkAdapter.DerivationNodeAdapter())
.add(TangemSdkAdapter.FirmwareVersionAdapter()) // For PrimaryCard model
.add(VisaActivationRemoteState.jsonAdapter)
.add(VisaCardActivationStatus.jsonAdapter)
.add(KotlinJsonAdapterFactory())
.addLast(KotlinJsonAdapterFactory())
.build()
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ScanResponse", PrimitiveKind.STRING)

View file

@ -1,5 +0,0 @@
package com.tangem.domain.visa.model
data class ActivationOrder(
val hash: String,
)

View file

@ -1,6 +0,0 @@
package com.tangem.domain.visa.model
data class SignedActivationOrder(
val activationOrder: ActivationOrder,
val signature: String,
)

View file

@ -4,6 +4,7 @@ enum class VisaActivationError(val message: String) {
BlockedForActivation("Card is blocked for activation"),
InvalidActivationState("Invalid activation state"),
WrongCard("Wrong card tapped"),
WrongRemoteState("Wrong remote state"),
MissingWallet("Missing wallet"),
MissingRootOTP("Missing root OTP"),
}

View file

@ -8,26 +8,6 @@ import kotlinx.serialization.Serializable
@JsonClass(generateAdapter = true)
data class VisaActivationInput(
@Json(name = "cardId") val cardId: String,
@Json(name = "cardPublicKey") val cardPublicKey: ByteArray,
@Json(name = "cardPublicKey") val cardPublicKey: String,
@Json(name = "isAccessCodeSet") val isAccessCodeSet: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as VisaActivationInput
if (cardId != other.cardId) return false
if (!cardPublicKey.contentEquals(other.cardPublicKey)) return false
if (isAccessCodeSet != other.isAccessCodeSet) return false
return true
}
override fun hashCode(): Int {
var result = cardId.hashCode()
result = 31 * result + cardPublicKey.contentHashCode()
result = 31 * result + isAccessCodeSet.hashCode()
return result
}
}
)

View file

@ -1,9 +1,51 @@
package com.tangem.domain.visa.model
import com.squareup.moshi.*
import kotlinx.serialization.Serializable
@Serializable
enum class VisaActivationRemoteState {
sealed class VisaActivationRemoteState {
@Serializable
data class CardWalletSignatureRequired(
val request: VisaCardWalletDataToSignRequest,
) : VisaActivationRemoteState()
@Serializable
data class CustomerWalletSignatureRequired(
val request: VisaCardWalletDataToSignRequest,
) : VisaActivationRemoteState()
@Serializable
data object PaymentAccountDeploying : VisaActivationRemoteState()
@Serializable
data object WaitingPinCode : VisaActivationRemoteState()
@Serializable
data object WaitingForActivationFinishing : VisaActivationRemoteState()
@Serializable
data object Activated : VisaActivationRemoteState()
@Serializable
data object BlockedForActivation : VisaActivationRemoteState()
companion object {
val jsonAdapter: VisaActivationRemoteState_JsonAdapter = VisaActivationRemoteState_JsonAdapter()
}
}
@Suppress("ClassNaming")
class VisaActivationRemoteState_Json(
@Json(name = "type") val type: VisaActivationRemoteState_Type,
@Json(name = "request") val request: VisaCardWalletDataToSignRequest? = null,
)
@Suppress("ClassNaming")
// do not rename
@JsonClass(generateAdapter = false)
enum class VisaActivationRemoteState_Type {
CardWalletSignatureRequired,
CustomerWalletSignatureRequired,
PaymentAccountDeploying,
@ -11,4 +53,51 @@ enum class VisaActivationRemoteState {
WaitingForActivationFinishing,
Activated,
BlockedForActivation,
}
@Suppress("ClassNaming")
class VisaActivationRemoteState_JsonAdapter {
@FromJson
fun fromJson(value: VisaActivationRemoteState_Json): VisaActivationRemoteState {
return when (value.type) {
VisaActivationRemoteState_Type.CardWalletSignatureRequired ->
VisaActivationRemoteState.CardWalletSignatureRequired(value.request!!)
VisaActivationRemoteState_Type.CustomerWalletSignatureRequired ->
VisaActivationRemoteState.CustomerWalletSignatureRequired(value.request!!)
VisaActivationRemoteState_Type.PaymentAccountDeploying -> VisaActivationRemoteState.PaymentAccountDeploying
VisaActivationRemoteState_Type.WaitingPinCode -> VisaActivationRemoteState.WaitingPinCode
VisaActivationRemoteState_Type.WaitingForActivationFinishing ->
VisaActivationRemoteState.WaitingForActivationFinishing
VisaActivationRemoteState_Type.Activated -> VisaActivationRemoteState.Activated
VisaActivationRemoteState_Type.BlockedForActivation -> VisaActivationRemoteState.BlockedForActivation
}
}
@ToJson
fun toJson(value: VisaActivationRemoteState): VisaActivationRemoteState_Json {
return when (value) {
is VisaActivationRemoteState.CardWalletSignatureRequired ->
VisaActivationRemoteState_Json(
type = VisaActivationRemoteState_Type.CardWalletSignatureRequired,
request = value.request,
)
is VisaActivationRemoteState.CustomerWalletSignatureRequired ->
VisaActivationRemoteState_Json(
type = VisaActivationRemoteState_Type.CustomerWalletSignatureRequired,
request = value.request,
)
is VisaActivationRemoteState.PaymentAccountDeploying ->
VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.PaymentAccountDeploying)
is VisaActivationRemoteState.WaitingPinCode ->
VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.WaitingPinCode)
is VisaActivationRemoteState.WaitingForActivationFinishing ->
VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.WaitingForActivationFinishing)
is VisaActivationRemoteState.Activated -> VisaActivationRemoteState_Json(
VisaActivationRemoteState_Type.Activated,
)
is VisaActivationRemoteState.BlockedForActivation ->
VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.BlockedForActivation)
}
}
}

View file

@ -14,4 +14,8 @@ data class VisaAuthTokens(
@Serializable
@JvmInline
value class RefreshToken(val value: String)
}
fun VisaAuthTokens.getAuthHeader(): String {
return "Bearer $accessToken"
}

View file

@ -1,7 +1,9 @@
package com.tangem.domain.visa.model
import com.squareup.moshi.FromJson
import com.squareup.moshi.Json
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import com.squareup.moshi.JsonClass
import com.squareup.moshi.ToJson
import kotlinx.serialization.Serializable
@Serializable
@ -9,19 +11,19 @@ sealed class VisaCardActivationStatus {
@Serializable
data class Activated(
@Json(name = "visaAuthTokens") val visaAuthTokens: VisaAuthTokens,
val visaAuthTokens: VisaAuthTokens,
) : VisaCardActivationStatus()
@Serializable
data class ActivationStarted(
@Json(name = "activationInput") val activationInput: VisaActivationInput,
@Json(name = "authTokens") val authTokens: VisaAuthTokens,
@Json(name = "remoteState") val remoteState: VisaActivationRemoteState,
val activationInput: VisaActivationInput,
val authTokens: VisaAuthTokens,
val remoteState: VisaActivationRemoteState,
) : VisaCardActivationStatus()
@Serializable
data class NotStartedActivation(
@Json(name = "activationInput") val activationInput: VisaActivationInput,
val activationInput: VisaActivationInput,
) : VisaCardActivationStatus()
@Serializable
@ -31,11 +33,70 @@ sealed class VisaCardActivationStatus {
data object RefreshTokenExpired : VisaCardActivationStatus()
companion object {
val jsonAdapter: PolymorphicJsonAdapterFactory<VisaCardActivationStatus>
get() = PolymorphicJsonAdapterFactory.of(VisaCardActivationStatus::class.java, "type")
.withSubtype(Activated::class.java, "Activated")
.withSubtype(ActivationStarted::class.java, "ActivationStarted")
.withSubtype(NotStartedActivation::class.java, "NotStartedActivation")
.withDefaultValue(Blocked)
val jsonAdapter: VisaCardActivationStatus_JsonAdapter = VisaCardActivationStatus_JsonAdapter()
}
}
@Suppress("ClassNaming")
class VisaCardActivationStatus_Json(
@Json(name = "type") val type: VisaCardActivationStatus_Type,
@Json(name = "activationInput") val activationInput: VisaActivationInput? = null,
@Json(name = "authTokens") val authTokens: VisaAuthTokens? = null,
@Json(name = "remoteState") val remoteState: VisaActivationRemoteState? = null,
)
// do not rename instances
@Suppress("ClassNaming")
@JsonClass(generateAdapter = false)
enum class VisaCardActivationStatus_Type {
Activated,
ActivationStarted,
NotStartedActivation,
Blocked,
RefreshTokenExpired,
}
@Suppress("ClassNaming")
class VisaCardActivationStatus_JsonAdapter {
@FromJson
fun fromJson(value: VisaCardActivationStatus_Json): VisaCardActivationStatus {
return when (value.type) {
VisaCardActivationStatus_Type.Activated -> VisaCardActivationStatus.Activated(value.authTokens!!)
VisaCardActivationStatus_Type.ActivationStarted -> VisaCardActivationStatus.ActivationStarted(
value.activationInput!!,
value.authTokens!!,
value.remoteState!!,
)
VisaCardActivationStatus_Type.NotStartedActivation -> VisaCardActivationStatus.NotStartedActivation(
value.activationInput!!,
)
VisaCardActivationStatus_Type.Blocked -> VisaCardActivationStatus.Blocked
VisaCardActivationStatus_Type.RefreshTokenExpired -> VisaCardActivationStatus.RefreshTokenExpired
}
}
@ToJson
fun toJson(value: VisaCardActivationStatus): VisaCardActivationStatus_Json {
return when (value) {
is VisaCardActivationStatus.Activated -> VisaCardActivationStatus_Json(
VisaCardActivationStatus_Type.Activated,
authTokens = value.visaAuthTokens,
)
is VisaCardActivationStatus.ActivationStarted -> VisaCardActivationStatus_Json(
VisaCardActivationStatus_Type.ActivationStarted,
value.activationInput,
value.authTokens,
value.remoteState,
)
is VisaCardActivationStatus.NotStartedActivation -> VisaCardActivationStatus_Json(
VisaCardActivationStatus_Type.NotStartedActivation,
activationInput = value.activationInput,
)
is VisaCardActivationStatus.Blocked -> VisaCardActivationStatus_Json(VisaCardActivationStatus_Type.Blocked)
is VisaCardActivationStatus.RefreshTokenExpired -> VisaCardActivationStatus_Json(
VisaCardActivationStatus_Type.RefreshTokenExpired,
)
}
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.domain.visa.model
data class VisaCardId(
val cardId: String,
val cardPublicKey: String,
)

View file

@ -0,0 +1,13 @@
package com.tangem.domain.visa.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = true)
data class VisaCardWalletDataToSignRequest(
@Json(name = "orderId") val orderId: String,
@Json(name = "customer_id") val customerId: String,
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
)

View file

@ -0,0 +1,12 @@
package com.tangem.domain.visa.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = true)
data class VisaCustomerWalletDataToSignRequest(
@Json(name = "orderId") val orderId: String,
@Json(name = "card_wallet_address") val cardWalletAddress: String,
)

View file

@ -1,11 +1,10 @@
package com.tangem.domain.visa.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VisaDataForApprove(
@SerialName("customerWalletCardId") val customerWalletCardId: String?,
@SerialName("targetAddress") val targetAddress: String,
@SerialName("approveHash") val approveHash: String,
val customerWalletCardId: String?,
val targetAddress: String,
val dataToSign: VisaDataToSignByCustomerWallet,
)

View file

@ -0,0 +1,15 @@
package com.tangem.domain.visa.model
data class VisaDataToSignByCardWallet(
val request: VisaCardWalletDataToSignRequest,
val hashToSign: String,
)
fun VisaDataToSignByCardWallet.sign(cardWalletAddress: String, rootOTP: String, otpCounter: Int, signature: String) =
VisaSignedActivationDataByCardWallet(
dataToSign = this,
cardWalletAddress = cardWalletAddress,
rootOTP = rootOTP,
otpCounter = otpCounter,
signature = signature,
)

View file

@ -0,0 +1,16 @@
package com.tangem.domain.visa.model
import kotlinx.serialization.Serializable
@Serializable
data class VisaDataToSignByCustomerWallet(
val request: VisaCustomerWalletDataToSignRequest,
val hashToSign: String,
)
fun VisaDataToSignByCustomerWallet.sign(signature: String, customerWalletAddress: String) =
VisaSignedDataByCustomerWallet(
dataToSign = this,
customerWalletAddress = customerWalletAddress,
signature = signature,
)

View file

@ -1,4 +0,0 @@
package com.tangem.domain.visa.model
@JvmInline
value class VisaRootOTP(val value: String)

View file

@ -0,0 +1,9 @@
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

@ -0,0 +1,7 @@
package com.tangem.domain.visa.model
data class VisaSignedDataByCustomerWallet(
val dataToSign: VisaDataToSignByCustomerWallet,
val customerWalletAddress: String,
val signature: String,
)

View file

@ -1,7 +1,6 @@
package com.tangem.domain.visa.repository
import com.tangem.domain.visa.model.ActivationOrder
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.*
interface VisaActivationRepository {
@ -9,9 +8,17 @@ interface VisaActivationRepository {
suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState
suspend fun getActivationOrderToSign(): ActivationOrder
suspend fun getCardWalletAcceptanceData(request: VisaCardWalletDataToSignRequest): VisaDataToSignByCardWallet
suspend fun getCustomerWalletAcceptanceData(
request: VisaCustomerWalletDataToSignRequest,
): VisaDataToSignByCustomerWallet
suspend fun activateCard(signedData: VisaSignedActivationDataByCardWallet)
suspend fun approveByCustomerWallet(signedData: VisaSignedDataByCustomerWallet)
interface Factory {
fun create(cardId: String): VisaActivationRepository
fun create(cardId: VisaCardId): VisaActivationRepository
}
}

View file

@ -8,7 +8,7 @@ interface VisaAuthRepository {
suspend fun getCardAuthChallenge(cardId: String, cardPublicKey: String): VisaAuthChallenge.Card
suspend fun getCustomerWalletAuthChallenge(cardId: String, walletPublicKey: String): VisaAuthChallenge.Wallet
suspend fun getCardWalletAuthChallenge(cardWalletAddress: String): VisaAuthChallenge.Wallet
suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): VisaAuthTokens

View file

@ -154,6 +154,7 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor(
appComponentContext = factoryContext,
config = OnboardingVisaApproveComponent.Config(
visaDataForApprove = route.visaDataForApprove,
scanResponse = model.currentScanResponse.value,
),
params = OnboardingVisaApproveComponent.Params(
childParams = childParams,

View file

@ -10,12 +10,16 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.common.visa.VisaUtilities
import com.tangem.domain.common.visa.VisaWalletPublicKeyUtility
import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
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.sdk.api.TangemSdkManager
import com.tangem.sdk.api.visa.VisaCardActivationTaskMode
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -28,6 +32,7 @@ import javax.inject.Inject
@ComponentScoped
internal class OnboardingVisaAccessCodeModel @Inject constructor(
paramsContainer: ParamsContainer,
visaActivationRepositoryFactory: VisaActivationRepository.Factory,
override val dispatchers: CoroutineDispatcherProvider,
@Suppress("UnusedPrivateMember")
private val tangemSdkManager: TangemSdkManager,
@ -36,11 +41,18 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
) : Model() {
private val params: OnboardingVisaAccessCodeComponent.Config = paramsContainer.require()
private val visaActivationRepository = visaActivationRepositoryFactory.create(
VisaCardId(
cardId = params.scanResponse.card.cardId,
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 _uiState = MutableStateFlow(getInitialState())
val uiState = _uiState.asStateFlow()
val onBack = MutableSharedFlow<Unit>()
val onDone = MutableSharedFlow<OnboardingVisaAccessCodeComponent.DoneEvent>()
@ -126,46 +138,65 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
val challengeToSign = runCatching {
visaAuthRepository.getCardAuthChallenge(
cardId = activationStatus.activationInput.cardId,
cardPublicKey = activationStatus.activationInput.cardPublicKey.toHexString(),
cardPublicKey = activationStatus.activationInput.cardPublicKey,
)
}.getOrElse {
loading(false)
// show alert
// TODO show alert
return@launch
}
val result = tangemSdkManager.activateVisaCard(
accessCode = accessCode,
challengeToSign = challengeToSign,
mode = VisaCardActivationTaskMode.Full(
accessCode = accessCode,
authorizationChallenge = challengeToSign,
),
activationInput = activationStatus.activationInput,
)
//
when (result) {
is CompletionResult.Success -> {
// TODO load approve data from backend
val targetAddress = "x9F65354e595284956599F2892fA4A4a87653D6E6"
val foundCardId = tryToFindExistingWalletCardId(targetAddress)
) as? CompletionResult.Success ?: run {
loading(false)
// TODO show alert
return@launch
}
modelScope.launch {
onDone.emit(
OnboardingVisaAccessCodeComponent.DoneEvent(
visaDataForApprove = VisaDataForApprove(
targetAddress = targetAddress,
approveHash = "48b55c482123a10ad9022f9f4c5dd95c",
customerWalletCardId = foundCardId,
),
walletFound = foundCardId != null,
newScanResponse = params.scanResponse.copy(
card = result.data.newCardDTO,
),
),
)
}
}
is CompletionResult.Failure -> {
loading(false)
// show alert
}
runCatching {
visaActivationRepository.activateCard(result.data.signedActivationData)
}.onFailure {
loading(false)
// TODO show alert
return@launch
}
// load data to sign by customer wallet for the next step
val dataToSign = runCatching {
visaActivationRepository.getCustomerWalletAcceptanceData(
VisaCustomerWalletDataToSignRequest(
orderId = result.data.signedActivationData.dataToSign.request.orderId,
cardWalletAddress = result.data.signedActivationData.cardWalletAddress,
),
)
}.getOrElse {
loading(false)
// TODO show alert
return@launch
}
val targetAddress = result.data.signedActivationData.dataToSign.request.customerWalletAddress
val foundCardId = tryToFindExistingWalletCardId(targetAddress)
modelScope.launch {
onDone.emit(
OnboardingVisaAccessCodeComponent.DoneEvent(
visaDataForApprove = VisaDataForApprove(
targetAddress = targetAddress,
customerWalletCardId = foundCardId,
dataToSign = dataToSign,
),
walletFound = foundCardId != null,
newScanResponse = params.scanResponse.copy(
card = result.data.newCardDTO,
),
),
)
}
}
}

View file

@ -9,6 +9,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.features.onboarding.v2.visa.impl.DefaultOnboardingVisaComponent
import com.tangem.features.onboarding.v2.visa.impl.child.approve.model.OnboardingVisaApproveModel
@ -43,6 +44,7 @@ internal class OnboardingVisaApproveComponent(
data class Config(
val visaDataForApprove: VisaDataForApprove,
val scanResponse: ScanResponse,
)
data class Params(

View file

@ -2,9 +2,12 @@ package com.tangem.features.onboarding.v2.visa.impl.child.approve.model
import androidx.compose.runtime.Stable
import com.tangem.common.CompletionResult
import com.tangem.common.extensions.toHexString
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.visa.model.VisaCardId
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.sdk.api.TangemSdkManager
@ -20,11 +23,19 @@ import javax.inject.Inject
@ComponentScoped
internal class OnboardingVisaApproveModel @Inject constructor(
paramsContainer: ParamsContainer,
visaActivationRepositoryFactory: VisaActivationRepository.Factory,
override val dispatchers: CoroutineDispatcherProvider,
private val tangemSdkManager: TangemSdkManager,
) : Model() {
private val params = paramsContainer.require<OnboardingVisaApproveComponent.Config>()
private val visaActivationRepository = visaActivationRepositoryFactory.create(
VisaCardId(
cardId = params.scanResponse.card.cardId,
cardPublicKey = params.scanResponse.card.cardPublicKey.toHexString(),
),
)
private val _uiState = MutableStateFlow(getInitialState())
val uiState = _uiState.asStateFlow()
@ -42,20 +53,21 @@ internal class OnboardingVisaApproveModel @Inject constructor(
modelScope.launch {
val result = tangemSdkManager.visaCustomerWalletApprove(
visaDataForApprove = params.visaDataForApprove,
)
when (result) {
is CompletionResult.Success -> {
// TODO make backend call
onDone.emit(Unit)
}
is CompletionResult.Failure -> {
loading(false)
// TODO show dialog
return@launch
}
) as? CompletionResult.Success ?: run {
loading(false)
// TODO show dialog
return@launch
}
runCatching {
visaActivationRepository.approveByCustomerWallet(result.data)
}.onFailure {
loading(false)
// TODO show dialog
return@launch
}
onDone.emit(Unit)
}
}

View file

@ -1,10 +1,12 @@
package com.tangem.features.onboarding.v2.visa.impl.child.inprogress.model
import androidx.compose.runtime.Stable
import com.tangem.common.extensions.toHexString
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -17,12 +19,17 @@ import javax.inject.Inject
@ComponentScoped
internal class OnboardingVisaInProgressModel @Inject constructor(
paramsContainer: ParamsContainer,
visaActivationRepositoryFactory: VisaActivationRepository.Factory,
override val dispatchers: CoroutineDispatcherProvider,
private val visaActivationRepositoryFactory: VisaActivationRepository.Factory,
) : Model() {
private val params = paramsContainer.require<OnboardingVisaInProgressComponent.Config>()
private val visaActivationRepository = visaActivationRepositoryFactory.create(params.scanResponse.card.cardId)
private val visaActivationRepository = visaActivationRepositoryFactory.create(
VisaCardId(
cardId = params.scanResponse.card.cardId,
cardPublicKey = params.scanResponse.card.cardPublicKey.toHexString(),
),
)
val onDone = MutableSharedFlow<Unit>()
init {

View file

@ -1,12 +1,14 @@
package com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.model
import androidx.compose.runtime.Stable
import com.tangem.common.extensions.toHexString
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.VisaCardId
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
@ -22,15 +24,19 @@ import javax.inject.Inject
@ComponentScoped
internal class OnboardingVisaOtherWalletModel @Inject constructor(
paramsContainer: ParamsContainer,
visaActivationRepositoryFactory: VisaActivationRepository.Factory,
override val dispatchers: CoroutineDispatcherProvider,
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
private val visaActivationRepositoryFactory: VisaActivationRepository.Factory,
) : Model() {
@Suppress("UnusedPrivateMember")
private val config = paramsContainer.require<OnboardingVisaOtherWalletComponent.Config>()
private val visaActivationRepository = visaActivationRepositoryFactory.create(config.scanResponse.card.cardId)
private val visaActivationRepository = visaActivationRepositoryFactory.create(
VisaCardId(
cardId = config.scanResponse.card.cardId,
cardPublicKey = config.scanResponse.card.cardPublicKey.toHexString(),
),
)
private val _uiState = MutableStateFlow(getInitialState())
val uiState = _uiState.asStateFlow()
@ -61,10 +67,10 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor(
}
private fun onShareClicked() {
shareManager.shareText("https://tangem.com/${config.visaDataForApprove.approveHash}")
shareManager.shareText("https://tangem.com/")
}
private fun onOpenInBrowserClicked() {
urlOpener.openUrl("https://tangem.com/${config.visaDataForApprove.approveHash}")
urlOpener.openUrl("https://tangem.com/")
}
}

View file

@ -52,12 +52,13 @@ internal class OnboardingVisaModel @Inject constructor(
fun navigateFromWelcome(route: OnboardingVisaRoute.Welcome) {
if (route.isWelcomeBack) {
val scanResponse = _currentScanResponse.value
val activationStatus = scanResponse.visaCardActivationStatus as? VisaCardActivationStatus
.ActivationStarted ?: error("Activation status is not correct for welcome back route")
val activationStatus = scanResponse.visaCardActivationStatus
as? VisaCardActivationStatus.ActivationStarted
?: error("Activation status is not correct for welcome back route")
when (activationStatus.remoteState) {
VisaActivationRemoteState.CardWalletSignatureRequired,
VisaActivationRemoteState.CustomerWalletSignatureRequired,
is VisaActivationRemoteState.CardWalletSignatureRequired,
is VisaActivationRemoteState.CustomerWalletSignatureRequired,
-> OnboardingVisaRoute.AccessCode
VisaActivationRemoteState.WaitingPinCode -> OnboardingVisaRoute.PinCode
VisaActivationRemoteState.WaitingForActivationFinishing -> OnboardingVisaRoute.InProgress

View file

@ -16,14 +16,14 @@ import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.visa.model.VisaActivationInput
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.operations.derivation.DerivationTaskResponse
import com.tangem.operations.preflightread.PreflightReadFilter
import com.tangem.operations.sign.SignHashResponse
import com.tangem.operations.wallet.CreateWalletResponse
import com.tangem.sdk.api.visa.VisaCardActivationResponse
import com.tangem.sdk.api.visa.VisaCardActivationTaskMode
@Suppress("TooManyFunctions")
interface TangemSdkManager {
@ -150,12 +150,13 @@ interface TangemSdkManager {
// region Visa-specific
suspend fun activateVisaCard(
accessCode: String,
challengeToSign: VisaAuthChallenge.Card?,
mode: VisaCardActivationTaskMode,
activationInput: VisaActivationInput,
): CompletionResult<VisaCardActivationResponse>
suspend fun visaCustomerWalletApprove(visaDataForApprove: VisaDataForApprove): CompletionResult<SignHashResponse>
suspend fun visaCustomerWalletApprove(
visaDataForApprove: VisaDataForApprove,
): CompletionResult<VisaSignedDataByCustomerWallet>
// endregion
}

View file

@ -1,11 +1,9 @@
package com.tangem.sdk.api.visa
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.visa.model.SignedActivationOrder
import com.tangem.domain.visa.model.VisaRootOTP
import com.tangem.domain.visa.model.VisaSignedActivationDataByCardWallet
data class VisaCardActivationResponse(
val signedActivationOrder: SignedActivationOrder,
val rootOTP: VisaRootOTP,
val signedActivationData: VisaSignedActivationDataByCardWallet,
val newCardDTO: CardDTO,
)

View file

@ -0,0 +1,20 @@
package com.tangem.sdk.api.visa
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaDataToSignByCardWallet
sealed class VisaCardActivationTaskMode {
/**
* Full activation process with getting remote activation status.
*/
data class Full(
val accessCode: String,
val authorizationChallenge: VisaAuthChallenge.Card,
) : VisaCardActivationTaskMode()
/**
* Activation process with only sign data by card wallet.
* This is used when activation process was interrupted and we need to finish it.
*/
data class SignOnly(val dataToSignByCardWallet: VisaDataToSignByCardWallet) : VisaCardActivationTaskMode()
}