Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-28 18:13:46 +04:00
parent 9163a956aa
commit b70933a1e0
47 changed files with 848 additions and 74 deletions

View file

@ -5,6 +5,7 @@ import com.squareup.moshi.Moshi
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.visa.TangemPayStorage
@ -19,6 +20,7 @@ import javax.inject.Singleton
private const val DEFAULT_KEY = "tangem_pay_default_key"
private const val ORDER_ID_KEY = "tangem_pay_order_id_key"
private const val WITHDRAW_ORDER_ID_KEY = "tangem_pay_withdraw_order_id_key"
@Singleton
internal class DefaultTangemPayStorage @Inject constructor(
@ -114,9 +116,38 @@ internal class DefaultTangemPayStorage @Inject constructor(
appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), false)
}
override suspend fun storeWithdrawOrder(userWalletId: UserWalletId, orderId: String) {
appPreferencesStore.editData { mutablePreferences ->
val orders = mutablePreferences.getObjectMap<String>(PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY)
.plus(createWithdrawOrderIdKey(userWalletId) to orderId)
mutablePreferences.setObjectMap(
key = PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY,
value = orders,
)
}
}
override suspend fun getWithdrawOrderId(userWalletId: UserWalletId): String? {
val orders = appPreferencesStore.getObjectMapSync<String>(PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY)
return orders[createWithdrawOrderIdKey(userWalletId)]
}
override suspend fun deleteWithdrawOrder(userWalletId: UserWalletId) {
appPreferencesStore.editData { mutablePreferences ->
val orders = mutablePreferences.getObjectMap<String>(PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY)
.minus(createWithdrawOrderIdKey(userWalletId))
mutablePreferences.setObjectMap(
key = PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY,
value = orders,
)
}
}
private fun createCustomerAddressKey(userWalletId: UserWalletId): String = userWalletId.stringValue
private fun createKey(address: String): String = "${DEFAULT_KEY}_$address"
private fun createOrderIdKey(address: String): String = "${ORDER_ID_KEY}_$address"
private fun createWithdrawOrderIdKey(userWalletId: UserWalletId): String = "${WITHDRAW_ORDER_ID_KEY}_$userWalletId"
}

View file

@ -11,6 +11,7 @@ import com.tangem.common.authentication.AuthenticationManager
import com.tangem.common.authentication.keystore.KeystoreManager
import com.tangem.common.core.*
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.services.secure.SecureStorage
import com.tangem.common.usersCode.UserCodeRepository
import com.tangem.core.analytics.Analytics
@ -45,6 +46,7 @@ import com.tangem.tap.domain.tasks.product.ResetBackupCardTask
import com.tangem.tap.domain.tasks.product.ResetToFactorySettingsTask
import com.tangem.tap.domain.tasks.product.ScanProductTask
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.TangemPaySignWithdrawalHashTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.tasks.visa.VisaCustomerWalletApproveTask
import com.tangem.tap.domain.twins.CreateFirstTwinWalletTask
@ -524,6 +526,15 @@ internal class DefaultTangemSdkManager(
}
}
override suspend fun getWithdrawalSignature(cardId: String, hash: String): CompletionResult<String> {
return coroutineScope {
runTaskAsyncReturnOnMain(
runnable = TangemPaySignWithdrawalHashTask(cardId = cardId, hash = hash.hexToBytes()),
cardId = cardId,
initialMessage = Message(resources.getStringSafe(R.string.initial_message_tap_header)),
)
}
}
// endregion
companion object {

View file

@ -219,5 +219,9 @@ class MockTangemSdkManager(
error("Not implemented")
}
override suspend fun getWithdrawalSignature(cardId: String, hash: String): CompletionResult<String> {
error("Not implemented")
}
// endregion
}

View file

@ -0,0 +1,121 @@
package com.tangem.tap.domain.tasks.visa
import com.tangem.blockchain.common.UnmarshalHelper
import com.tangem.common.CompletionResult
import com.tangem.common.card.Card
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.TangemSdkError
import com.tangem.common.extensions.toDecompressedPublicKey
import com.tangem.common.extensions.toHexString
import com.tangem.core.error.ext.tangemError
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.visa.error.VisaActivationError
import com.tangem.operations.ScanTask
import com.tangem.operations.derivation.DeriveWalletPublicKeyTask
import com.tangem.operations.sign.SignHashCommand
class TangemPaySignWithdrawalHashTask(
private val cardId: String,
private val hash: ByteArray,
) : CardSessionRunnable<String> {
override fun run(session: CardSession, callback: CompletionCallback<String>) {
val card = session.environment.card ?: run {
callback(CompletionResult.Failure(TangemSdkError.MissingPreflightRead()))
return
}
if (card.cardId != cardId) {
callback(CompletionResult.Failure(VisaActivationError.CardIdNotMatched.tangemError))
return
}
proceedSign(card, session, callback)
}
private fun proceedSign(card: Card, session: CardSession, callback: CompletionCallback<String>) {
val derivationPath = VisaUtilities.customDerivationPath
val wallet = card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } ?: run {
callback(CompletionResult.Failure(VisaActivationError.MissingWallet.tangemError))
return
}
val derivationTask = DeriveWalletPublicKeyTask(
walletPublicKey = wallet.publicKey,
derivationPath = derivationPath,
)
derivationTask.run(session) { result ->
when (result) {
is CompletionResult.Success -> {
signData(
targetWalletPublicKey = wallet.publicKey,
derivationPath = derivationPath,
session = session,
extendedPublicKey = result.data,
callback = callback,
)
}
is CompletionResult.Failure -> {
callback(CompletionResult.Failure(result.error))
}
}
}
}
private fun signData(
targetWalletPublicKey: ByteArray,
derivationPath: DerivationPath?,
extendedPublicKey: ExtendedPublicKey?,
session: CardSession,
callback: CompletionCallback<String>,
) {
val signTask = SignHashCommand(
hash = hash,
walletPublicKey = targetWalletPublicKey,
derivationPath = derivationPath,
)
signTask.run(session) { result ->
when (result) {
is CompletionResult.Success -> {
val rsvSignature = UnmarshalHelper.unmarshalSignatureExtended(
signature = result.data.signature,
hash = hash,
publicKey = extendedPublicKey?.publicKey?.toDecompressedPublicKey()
?: targetWalletPublicKey.toDecompressedPublicKey(),
).asRSVLegacyEVM().toHexString().lowercase()
scanCard(
session = session,
callback = callback,
signedData = rsvSignature,
)
}
is CompletionResult.Failure -> {
callback(CompletionResult.Failure(result.error))
}
}
}
}
private fun scanCard(signedData: String, session: CardSession, callback: CompletionCallback<String>) {
val scanTask = ScanTask()
scanTask.run(session) { result ->
when (result) {
is CompletionResult.Success -> {
callback(CompletionResult.Success(signedData))
}
is CompletionResult.Failure -> {
callback(CompletionResult.Failure(result.error))
}
}
}
}
}

View file

@ -328,6 +328,7 @@ internal class ChildFactory @Inject constructor(
cryptoAmount = tangemPayInput.cryptoAmount,
fiatAmount = tangemPayInput.fiatAmount,
depositAddress = tangemPayInput.depositAddress,
isWithdrawal = tangemPayInput.isWithdrawal,
)
},
),

View file

@ -197,6 +197,7 @@ sealed class AppRoute(val path: String) : Route {
val cryptoAmount: SerializedBigDecimal,
val fiatAmount: SerializedBigDecimal,
val depositAddress: String,
val isWithdrawal: Boolean,
)
}

View file

@ -166,4 +166,16 @@ interface TangemPayApi {
@Header("Authorization") authHeader: String,
@Body body: FreezeUnfreezeCardRequest,
): ApiResponse<FreezeUnfreezeCardResponse>
@POST("v1/customer/card/withdraw/data")
suspend fun getWithdrawData(
@Header("Authorization") authHeader: String,
@Body body: WithdrawDataRequest,
): ApiResponse<WithdrawDataResponse>
@POST("v1/customer/card/withdraw")
suspend fun withdraw(
@Header("Authorization") authHeader: String,
@Body body: WithdrawRequest,
): ApiResponse<WithdrawResponse>
}

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class WithdrawDataRequest(
@Json(name = "amount_in_cents") val amountInCents: String,
@Json(name = "recipient_address") val recipientAddress: String,
)

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class WithdrawRequest(
@Json(name = "amount_in_cents") val amountInCents: String,
@Json(name = "recipient_address") val recipientAddress: String,
@Json(name = "admin_signature") val adminSignature: String,
@Json(name = "admin_salt") val adminSalt: String,
@Json(name = "sender_address") val senderAddress: String,
)

View file

@ -0,0 +1,17 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class WithdrawDataResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "hash") val hash: String,
@Json(name = "salt") val salt: String,
@Json(name = "sender_address") val senderAddress: String,
)
}

View file

@ -0,0 +1,17 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class WithdrawResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "order_id") val orderId: String,
@Json(name = "status") val status: String,
@Json(name = "type") val type: String,
)
}

View file

@ -145,6 +145,8 @@ object PreferencesKeys {
intPreferencesKey(name = "tronNetworkFeeNotificationShowCount")
}
val TANGEM_PAY_WITHDRAW_ORDERS_KEY by lazy { stringPreferencesKey(name = "tangemPayWithdrawOrders") }
fun getShouldShowNotificationKey(key: String) = booleanPreferencesKey("showShowNotificationUM_$key")
// endregion

View file

@ -24,5 +24,11 @@ interface TangemPayStorage {
suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId)
suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean?
suspend fun storeWithdrawOrder(userWalletId: UserWalletId, orderId: String)
suspend fun getWithdrawOrderId(userWalletId: UserWalletId): String?
suspend fun deleteWithdrawOrder(userWalletId: UserWalletId)
suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String)
}

View file

@ -32,6 +32,7 @@
<string name="account_details_archive_description">You are archiving this account, but you can always get it back.</string>
<string name="account_details_archiving">Archiving...</string>
<string name="account_details_title">Account</string>
<string name="account_edit_failure_dialog_title">Cant edit the account</string>
<string name="account_edit_success_message">Account saved</string>
<string name="account_for_rewards">Account for rewards</string>
<string name="account_form_account_index">Account #%s — used for address derivation.</string>
@ -444,7 +445,7 @@
<string name="disclaimer_error_loading">Check your internet connection or switch to a different network</string>
<string name="disclaimer_title">Terms of service</string>
<string name="domain_receive_assets_default_address">Default Address</string>
<string name="domain_receive_assets_legacy_address">Legacy Address</string>
<string name="domain_receive_assets_legacy_address">Legacy %s Address</string>
<string name="domain_receive_assets_navigation_title">Receive assets</string>
<string name="domain_receive_assets_network_name_address">%s address</string>
<string name="domain_receive_assets_onboarding_description">Sending assets in other networks will result in permanent loss.</string>
@ -1441,9 +1442,21 @@
<string name="tangempay_card_details_swap_description">Swap any asset in your portfolio for card</string>
<string name="tangempay_card_details_title">Card details</string>
<string name="tangempay_card_details_unfreeze_card">Unfreeze Card</string>
<string name="tangempay_card_details_withdraw">Withdraw</string>
<string name="tangempay_card_details_withdraw_error_title">Withdraw unavailable now</string>
<string name="tangempay_card_details_withdraw_in_progress_description">You can\'t initiate swap or new withdrawal till the current one is finished</string>
<string name="tangempay_card_details_withdraw_in_progress_title">Withdrawal in progress</string>
<string name="tangempay_failed_to_issue_card">Failed to issue card</string>
<string name="tangempay_failed_to_issue_card_retry_description">A technical error has occurred, please try again by clicking the button below.</string>
<string name="tangempay_failed_to_issue_card_support_description">A technical error has occurred, please contact support.</string>
<string name="tangempay_issue_card_notification_description">It usually takes up to 15 minutes</string>
<string name="tangempay_issue_card_notification_title">Setting up your Tangem Card</string>
<string name="tangempay_issuing_your_card">Issuing your card</string>
<string name="tangempay_issuing_your_card_description">Were getting your card ready. This may take a little time.</string>
<string name="tangempay_kyc_card_ready_notification_title">Tangem Pay</string>
<string name="tangempay_kyc_failed_description">We could not verify your profile. If you have any questions, please contact support.</string>
<string name="tangempay_kyc_failed_title">Unfortunately, we couldn\'t verify your identity </string>
<string name="tangempay_kyc_in_progress">KYC in progress</string>
<string name="tangempay_kyc_in_progress_notification_button">View Status</string>
<string name="tangempay_kyc_in_progress_notification_title">KYC in progress for Tangem Pay</string>
<string name="tangempay_onboarding_get_card_button_text">Get card</string>
@ -1459,6 +1472,7 @@
<string name="tangempay_service_unavailable_description">Were fixing a technical issue. Please try again later.</string>
<string name="tangempay_service_unavailable_title">Service temporarily unavailable</string>
<string name="tangempay_service_unreachable_try_later">The service is currently unreachable. Please try again later.</string>
<string name="tangempay_sync_needed">Sync needed</string>
<string name="tangempay_temporarily_unavailable">Tangem Pay is temporarily unavailable</string>
<string name="tangempay_title">Tangem Pay</string>
<string name="tangempay_use_tangem_device_to_restore_payment_account">Use your card or ring to restore access to your payment account</string>

View file

@ -16,7 +16,7 @@ import com.tangem.domain.models.ReceiveAddressModel.NameService
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayTopUpData
import com.tangem.domain.pay.TangemPayTopUpDataFactory
import com.tangem.domain.pay.TangemPaySwapDataFactory
import timber.log.Timber
import java.math.BigDecimal
import javax.inject.Inject
@ -30,11 +30,11 @@ private const val TOKEN_NAME = "USDC"
private const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
private const val TOKEN_DECIMALS = 6
internal class DefaultTangemPayTopUpDataFactory @Inject constructor(
internal class DefaultTangemPaySwapDataFactory @Inject constructor(
@NetworkMoshi moshi: Moshi,
private val tangemPayWalletsManager: TangemPayWalletsManager,
excludedBlockchains: ExcludedBlockchains,
) : TangemPayTopUpDataFactory {
) : TangemPaySwapDataFactory {
private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
CryptoCurrencyFactory(excludedBlockchains)

View file

@ -27,4 +27,13 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor(
.mapLeft { IllegalStateException("TangemPay token refresh failed. Error code: ${it.errorCode}") }
.bind()
}
override suspend fun getWithdrawalSignature(cardId: String, hash: String): Either<Throwable, String> {
return when (val signResult = tangemSdkManager.getWithdrawalSignature(cardId, hash)) {
is CompletionResult.Failure<*> -> Either.Left(
IllegalStateException("TangemPay sign hash failed: ${signResult.error}"),
)
is CompletionResult.Success<String> -> Either.Right(signResult.data)
}
}
}

View file

@ -1,18 +1,15 @@
package com.tangem.data.pay.di
import com.tangem.data.pay.DefaultTangemPayTopUpDataFactory
import com.tangem.data.pay.repository.DefaultKycRepository
import com.tangem.data.pay.repository.DefaultOnboardingRepository
import com.tangem.data.pay.repository.DefaultTangemPayCardDetailsRepository
import com.tangem.data.pay.repository.DefaultTangemPayTxHistoryRepository
import com.tangem.data.pay.DefaultTangemPaySwapDataFactory
import com.tangem.data.pay.repository.*
import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase
import com.tangem.domain.pay.TangemPayTopUpDataFactory
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.data.pay.usecase.DefaultTangemPayWithdrawUseCase
import com.tangem.domain.pay.TangemPaySwapDataFactory
import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import dagger.Binds
import dagger.Module
@ -43,7 +40,15 @@ internal interface TangemPayDataModule {
@Binds
@Singleton
fun bindDataForReceiveFactory(factory: DefaultTangemPayTopUpDataFactory): TangemPayTopUpDataFactory
fun bindTangemPaySwapRepository(repository: DefaultTangemPaySwapRepository): TangemPaySwapRepository
@Binds
@Singleton
fun bindCustomerOrderRepository(repository: DefaultCustomerOrderRepository): CustomerOrderRepository
@Binds
@Singleton
fun bindTangemPaySwapDataFactory(factory: DefaultTangemPaySwapDataFactory): TangemPaySwapDataFactory
@Binds
@Singleton
@ -51,13 +56,21 @@ internal interface TangemPayDataModule {
impl: DefaultGetTangemPayCurrencyStatusUseCase,
): GetTangemPayCurrencyStatusUseCase
@Binds
@Singleton
fun bindTangemPayWithdrawUseCase(impl: DefaultTangemPayWithdrawUseCase): TangemPayWithdrawUseCase
companion object {
@Provides
@Singleton
fun provideTangemPayMainScreenCustomerInfoUseCase(
repository: OnboardingRepository,
customerOrderRepository: CustomerOrderRepository,
): TangemPayMainScreenCustomerInfoUseCase {
return TangemPayMainScreenCustomerInfoUseCase(repository = repository)
return TangemPayMainScreenCustomerInfoUseCase(
repository = repository,
customerOrderRepository = customerOrderRepository,
)
}
@Provides

View file

@ -0,0 +1,46 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.visa.error.VisaApiError
import javax.inject.Inject
internal class DefaultCustomerOrderRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val tangemPayStorage: TangemPayStorage,
) : CustomerOrderRepository {
override suspend fun getOrderStatus(
userWalletId: UserWalletId,
orderId: String,
): Either<VisaApiError, OrderStatus> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getOrder(authHeader = authHeader, orderId = orderId)
}.map { response ->
when (response.result?.status) {
null -> OrderStatus.UNKNOWN
OrderStatus.NEW.apiName -> OrderStatus.NEW
OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING
OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED
else -> OrderStatus.CANCELED
}
}
}
override suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean {
val orderId = tangemPayStorage.getWithdrawOrderId(userWalletId)
if (orderId == null) return false
val status = getOrderStatus(userWalletId, orderId).getOrNull()
val hasActiveOrder = status == OrderStatus.NEW || status == OrderStatus.PROCESSING
if (!hasActiveOrder) tangemPayStorage.deleteWithdrawOrder(userWalletId)
return hasActiveOrder
}
}

View file

@ -14,7 +14,6 @@ import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.TangemPayCardFrozenState
@ -170,23 +169,6 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
}
override suspend fun getOrderStatus(
userWalletId: UserWalletId,
orderId: String,
): Either<VisaApiError, OrderStatus> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getOrder(authHeader = authHeader, orderId = orderId)
}.map { response ->
when (response.result?.status) {
null -> OrderStatus.UNKNOWN
OrderStatus.NEW.apiName -> OrderStatus.NEW
OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING
OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED
else -> OrderStatus.CANCELED
}
}
}
override suspend fun checkCustomerWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> {
val hasTangemPay = tangemPayStorage.checkCustomerWalletResult(userWalletId)
if (hasTangemPay == true) {

View file

@ -42,8 +42,12 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
tangemPayApi.getCardBalance(authHeader)
}.result ?: error("Cannot get card balance")
TangemPayCardBalance(
balance = result.fiat.availableBalance,
fiatBalance = result.fiat.availableBalance,
currencyCode = result.fiat.currency,
cryptoBalance = result.crypto.balance,
chainId = result.crypto.chainId,
depositAddress = result.crypto.depositAddress,
contractAddress = result.crypto.tokenContractAddress,
)
}
}

View file

@ -0,0 +1,106 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.data.common.quote.QuotesFetcher
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.WithdrawDataRequest
import com.tangem.datasource.api.pay.models.request.WithdrawRequest
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.repository.TangemPaySwapRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.Currency
import java.util.Locale
import javax.inject.Inject
@Suppress("LongParameterList")
internal class DefaultTangemPaySwapRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val authDataSource: TangemPayAuthDataSource,
private val userWalletsListManager: UserWalletsListManager,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val quotesFetcher: QuotesFetcher,
private val tangemPayStorage: TangemPayStorage,
) : TangemPaySwapRepository {
override suspend fun withdraw(
userWalletId: UserWalletId,
receiverAddress: String,
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
): Either<UniversalError, Unit> {
val amountInCents = getAmountInCents(cryptoAmount, cryptoCurrencyId)
if (amountInCents.isNullOrEmpty()) return Either.Left(VisaApiError.WithdrawalDataError)
return requestHelper.makeSafeRequest(userWalletId) { authHeader ->
val request = WithdrawDataRequest(amountInCents = amountInCents, recipientAddress = receiverAddress)
tangemPayApi.getWithdrawData(authHeader = authHeader, body = request)
}.map { data ->
val result = data.result
if (result == null) return Either.Left(VisaApiError.WithdrawalDataError)
val signature = authDataSource.getWithdrawalSignature(cardId = getCardId(userWalletId), hash = result.hash)
.getOrNull()
if (signature == null) return Either.Left(VisaApiError.SignWithdrawError)
requestHelper.makeSafeRequest(userWalletId) { authHeader ->
val request = WithdrawRequest(
amountInCents = amountInCents,
recipientAddress = receiverAddress,
adminSalt = result.salt,
senderAddress = result.senderAddress,
adminSignature = signature,
)
tangemPayApi.withdraw(authHeader = authHeader, body = request)
}
.mapLeft { return Either.Left(VisaApiError.WithdrawError) }
.map { response ->
val orderId = response.result?.orderId
if (orderId != null) tangemPayStorage.storeWithdrawOrder(userWalletId, orderId)
}
}
}
private suspend fun getAmountInCents(cryptoAmount: BigDecimal, cryptoCurrencyId: CryptoCurrency.RawID): String? {
val fiatRate = getFiatRate(cryptoCurrencyId) ?: return null
val amountInDollars = cryptoAmount.multiply(fiatRate)
val defaultFractionDigits = Currency.getInstance(Locale.US).defaultFractionDigits
return amountInDollars
.setScale(defaultFractionDigits, RoundingMode.HALF_UP)
.movePointRight(defaultFractionDigits)
.longValueExact()
.toString()
}
private suspend fun getFiatRate(cryptoCurrencyId: CryptoCurrency.RawID): BigDecimal? {
val quotes = quotesFetcher.fetch(
fiatCurrencyId = Currency.getInstance(Locale.US).currencyCode,
currencyId = cryptoCurrencyId.value,
field = QuotesFetcher.Field.PRICE,
).getOrNull()
return quotes?.quotes[cryptoCurrencyId.value]?.price
}
private fun getCardId(userWalletId: UserWalletId): String {
val userWallet = if (hotWalletFeatureToggles.isHotWalletEnabled) {
userWalletsListRepository.userWallets.value?.firstOrNull { it.walletId == userWalletId }
} else {
userWalletsListManager.userWalletsSync.firstOrNull { it.walletId == userWalletId }
} ?: error("No User Wallet found")
return if (userWallet is UserWallet.Cold) {
userWallet.cardId
} else {
TODO("[REDACTED_JIRA]")
}
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.data.pay.usecase
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.repository.TangemPaySwapRepository
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import java.math.BigDecimal
import javax.inject.Inject
internal class DefaultTangemPayWithdrawUseCase @Inject constructor(
private val repository: TangemPaySwapRepository,
) : TangemPayWithdrawUseCase {
override suspend fun invoke(
userWalletId: UserWalletId,
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
receiverCexAddress: String,
): Either<UniversalError, Unit> {
return repository.withdraw(
userWalletId = userWalletId,
cryptoAmount = cryptoAmount,
receiverAddress = receiverCexAddress,
cryptoCurrencyId = cryptoCurrencyId,
)
}
}

View file

@ -62,6 +62,9 @@ sealed class VisaApiError(
data object RefreshTokenExpired : VisaApiError(104004001)
data object NotPaeraCustomer : VisaApiError(104004002)
data object WithdrawalDataError : VisaApiError(104004003)
data object SignWithdrawError : VisaApiError(104004004)
data object WithdrawError : VisaApiError(104004005)
companion object {
fun fromBackendError(backendErrorCode: Int): VisaApiError {

View file

@ -7,7 +7,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigDecimal
interface TangemPayTopUpDataFactory {
interface TangemPaySwapDataFactory {
fun create(
depositAddress: String,

View file

@ -9,4 +9,6 @@ interface TangemPayAuthDataSource {
suspend fun produceInitialCredentials(cardId: String): Either<Throwable, TangemPayInitialCredentials>
suspend fun refreshAuthTokens(refreshToken: String): Either<Throwable, TangemPayAuthTokens>
suspend fun getWithdrawalSignature(cardId: String, hash: String): Either<Throwable, String>
}

View file

@ -3,6 +3,10 @@ package com.tangem.domain.pay.model
import java.math.BigDecimal
data class TangemPayCardBalance(
val balance: BigDecimal,
val fiatBalance: BigDecimal,
val currencyCode: String,
val cryptoBalance: BigDecimal,
val chainId: Int,
val depositAddress: String,
val contractAddress: String,
)

View file

@ -0,0 +1,13 @@
package com.tangem.domain.pay.repository
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.visa.error.VisaApiError
interface CustomerOrderRepository {
suspend fun getOrderStatus(userWalletId: UserWalletId, orderId: String): Either<VisaApiError, OrderStatus>
suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean
}

View file

@ -4,7 +4,6 @@ import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.visa.error.VisaApiError
import kotlinx.coroutines.Job
@ -21,8 +20,10 @@ interface OnboardingRepository {
suspend fun createOrder(userWalletId: UserWalletId): Job
suspend fun clearOrderId(userWalletId: UserWalletId)
suspend fun getOrderId(userWalletId: UserWalletId): String?
suspend fun getOrderStatus(userWalletId: UserWalletId, orderId: String): Either<VisaApiError, OrderStatus>
suspend fun checkCustomerWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean>
fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo?
}

View file

@ -0,0 +1,17 @@
package com.tangem.domain.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigDecimal
interface TangemPaySwapRepository {
suspend fun withdraw(
userWalletId: UserWalletId,
receiverAddress: String,
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
): Either<UniversalError, Unit>
}

View file

@ -9,6 +9,7 @@ import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.MainScreenCustomerInfo
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayCustomerInfoError
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import timber.log.Timber
@ -21,6 +22,7 @@ private const val TAG = "TangemPayMainScreenCustomerInfoUseCase"
*/
class TangemPayMainScreenCustomerInfoUseCase(
private val repository: OnboardingRepository,
private val customerOrderRepository: CustomerOrderRepository,
) {
suspend operator fun invoke(
@ -74,7 +76,7 @@ class TangemPayMainScreenCustomerInfoUseCase(
userWalletId: UserWalletId,
orderId: String,
): Either<TangemPayCustomerInfoError, MainScreenCustomerInfo> {
return repository.getOrderStatus(userWalletId, orderId = orderId)
return customerOrderRepository.getOrderStatus(userWalletId, orderId = orderId)
.fold(
ifLeft = { error ->
error.mapErrorForCustomer().left()

View file

@ -0,0 +1,17 @@
package com.tangem.domain.tangempay
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigDecimal
interface TangemPayWithdrawUseCase {
suspend operator fun invoke(
userWalletId: UserWalletId,
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
receiverCexAddress: String,
): Either<UniversalError, Unit>
}

View file

@ -20,6 +20,7 @@ interface SwapComponent : ComposableContentComponent {
val cryptoAmount: BigDecimal,
val fiatAmount: BigDecimal,
val depositAddress: String,
val isWithdrawal: Boolean,
)
}

View file

@ -1,7 +1,13 @@
package com.tangem.feature.swap.domain.models.ui
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import java.math.BigDecimal
sealed class SwapTransactionState {
@ -16,6 +22,31 @@ sealed class SwapTransactionState {
val timestamp: Long,
) : SwapTransactionState()
data class TangemPayWithdrawalData(
val cryptoAmount: BigDecimal,
val cryptoCurrencyId: CryptoCurrency.RawID,
val cexAddress: String,
val fromAmount: String?,
val fromAmountValue: BigDecimal?,
val toAmount: String?,
val toAmountValue: BigDecimal?,
val storeData: StoreTransactionData,
) : SwapTransactionState() {
data class StoreTransactionData(
val currencyToSend: CryptoCurrencyStatus,
val currencyToGet: CryptoCurrencyStatus,
val fromAccount: Account.CryptoPortfolio?,
val toAccount: Account.CryptoPortfolio?,
val amount: SwapAmount,
val swapProvider: SwapProvider,
val swapDataModel: SwapDataModel,
val txExternalUrl: String?,
val txExternalId: String?,
val averageDuration: Int?,
)
}
data object DemoMode : SwapTransactionState()
sealed class Error : SwapTransactionState() {

View file

@ -61,7 +61,8 @@ interface SwapInteractor {
* @param currencyToSend [Currency]
* @param currencyToGet [Currency]
* @param amountToSwap amount to swap
* @param fee for tx
* @param fee for tx (can be null only for tangem pay withdrawal)
* @return [SwapTransactionState]
*/
@Suppress("LongParameterList")
@ -75,8 +76,9 @@ interface SwapInteractor {
toAccount: Account.CryptoPortfolio?,
amountToSwap: String,
includeFeeInAmount: IncludeFeeInAmount,
fee: TxFee,
fee: TxFee?,
expressOperationType: ExpressOperationType,
isTangemPayWithdrawal: Boolean,
): SwapTransactionState
// suspend fun updateQuotesStateWithSelectedFee(
@ -115,6 +117,21 @@ interface SwapInteractor {
fun getNativeToken(networkId: String): CryptoCurrency
@Suppress("LongParameterList")
suspend fun storeSwapTransaction(
currencyToSend: CryptoCurrencyStatus,
currencyToGet: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toAccount: Account.CryptoPortfolio?,
amount: SwapAmount,
swapProvider: SwapProvider,
swapDataModel: SwapDataModel,
timestamp: Long,
txExternalUrl: String? = null,
txExternalId: String? = null,
averageDuration: Int? = null,
)
interface Factory {
fun create(selectedWalletId: UserWalletId): SwapInteractor
}

View file

@ -709,6 +709,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
return result
}
@Suppress("NullableToStringCall")
override suspend fun onSwap(
swapProvider: SwapProvider,
swapData: SwapDataModel?,
@ -718,8 +719,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
toAccount: Account.CryptoPortfolio?,
amountToSwap: String,
includeFeeInAmount: IncludeFeeInAmount,
fee: TxFee,
fee: TxFee?,
expressOperationType: ExpressOperationType,
isTangemPayWithdrawal: Boolean,
): SwapTransactionState {
Timber.i(
"""
@ -757,6 +759,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
txFee = fee,
swapProvider = swapProvider,
expressOperationType = expressOperationType,
isTangemPayWithdrawal = isTangemPayWithdrawal,
)
}
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
@ -773,6 +776,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
amountToSwap = amountToSwap,
)
} else {
if (fee == null) return SwapTransactionState.Error.UnknownError
onSwapDex(
provider = swapProvider,
networkId = currencyToSend.currency.network.backendId,
@ -978,16 +982,17 @@ internal class SwapInteractorImpl @AssistedInject constructor(
).getOrNull() ?: error("failed to create extras")
}
@Suppress("LongMethod")
@Suppress("LongMethod", "CanBeNonNullable")
private suspend fun onSwapCex(
currencyToSend: CryptoCurrencyStatus,
currencyToGet: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toAccount: Account.CryptoPortfolio?,
amount: SwapAmount,
txFee: TxFee,
txFee: TxFee?,
swapProvider: SwapProvider,
expressOperationType: ExpressOperationType,
isTangemPayWithdrawal: Boolean,
): SwapTransactionState {
val exchangeData = repository.getExchangeData(
userWallet = userWallet,
@ -1010,6 +1015,33 @@ internal class SwapInteractorImpl @AssistedInject constructor(
val exchangeDataCex =
exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.Error.UnknownError
if (isTangemPayWithdrawal) {
return SwapTransactionState.TangemPayWithdrawalData(
cryptoAmount = amount.value,
cryptoCurrencyId = requireNotNull(currencyToSend.currency.id.rawCurrencyId),
cexAddress = exchangeDataCex.txTo,
fromAmount = amount.toStringWithRightOffset(),
fromAmountValue = amount.value,
toAmount = amountFormatter.formatSwapAmountToUI(
exchangeData.toTokenAmount,
currencyToGet.currency.symbol,
),
toAmountValue = exchangeData.toTokenAmount.value,
storeData = SwapTransactionState.TangemPayWithdrawalData.StoreTransactionData(
currencyToSend = currencyToSend,
currencyToGet = currencyToGet,
fromAccount = fromAccount,
toAccount = toAccount,
amount = amount,
swapProvider = swapProvider,
swapDataModel = exchangeData,
txExternalUrl = "",
txExternalId = exchangeDataCex.externalTxId,
averageDuration = null,
),
)
}
val userWallet = userWallet
if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) {
return SwapTransactionState.Error.UnknownError
@ -1017,7 +1049,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
val txData = createTransferTransactionUseCase(
amount = amount.value.convertToSdkAmount(currencyToSend),
fee = txFee.fee,
fee = requireNotNull(txFee).fee,
memo = exchangeDataCex.txExtraId,
destination = exchangeDataCex.txTo,
userWalletId = userWalletId,
@ -1087,7 +1119,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
)
}
private suspend fun storeSwapTransaction(
override suspend fun storeSwapTransaction(
currencyToSend: CryptoCurrencyStatus,
currencyToGet: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
@ -1096,9 +1128,9 @@ internal class SwapInteractorImpl @AssistedInject constructor(
swapProvider: SwapProvider,
swapDataModel: SwapDataModel,
timestamp: Long,
txExternalUrl: String? = null,
txExternalId: String? = null,
averageDuration: Int? = null,
txExternalUrl: String?,
txExternalId: String?,
averageDuration: Int?,
) {
swapTransactionRepository.storeTransaction(
userWalletId = userWalletId,

View file

@ -22,6 +22,7 @@ dependencies {
implementation(projects.common.routing)
implementation(projects.common.ui)
implementation(projects.core.decompose) // For Route supertype
implementation(projects.core.error)
/** Domain modules **/
implementation(projects.domain.models)

View file

@ -45,6 +45,7 @@ import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
@ -115,6 +116,7 @@ internal class SwapModel @Inject constructor(
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase,
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
) : Model() {
private val params = paramsContainer.require<SwapComponent.Params>()
@ -555,6 +557,7 @@ internal class SwapModel @Inject constructor(
selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
isReverseSwapPossible = isReverseSwapPossible(),
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
hideFee = tangemPayInput?.isWithdrawal == true,
)
if (uiState.notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }) {
analyticsEventHandler.send(
@ -702,7 +705,7 @@ internal class SwapModel @Inject constructor(
val fromCurrency = requireNotNull(dataState.fromCryptoCurrency)
val fee = dataState.selectedFee
if (fee == null) {
if (fee == null && tangemPayInput?.isWithdrawal != true) {
makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
return
}
@ -719,13 +722,18 @@ internal class SwapModel @Inject constructor(
includeFeeInAmount = lastLoadedQuotesState.preparedSwapConfigState.includeFeeInAmount,
fee = fee,
expressOperationType = ExpressOperationType.SWAP,
isTangemPayWithdrawal = tangemPayInput?.isWithdrawal == true,
)
}.onSuccess {
when (it) {
}.onSuccess { swapTransactionState ->
when (swapTransactionState) {
is SwapTransactionState.TxSent -> {
if (fee == null) {
makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
return@onSuccess
}
sendSuccessSwapEvent(fromCurrency.currency, fee.feeType)
val url = getExplorerTransactionUrlUseCase(
txHash = it.txHash,
txHash = swapTransactionState.txHash,
networkId = fromCurrency.currency.network.id,
).getOrElse {
Timber.i("tx hash explore not supported")
@ -735,11 +743,11 @@ internal class SwapModel @Inject constructor(
updateWalletBalance()
uiState = stateBuilder.createSuccessState(
uiState = uiState,
swapTransactionState = it,
swapTransactionState = swapTransactionState,
dataState = dataState,
txUrl = url,
onExploreClick = {
if (it.txHash.isNotEmpty()) {
if (swapTransactionState.txHash.isNotEmpty()) {
urlOpener.openUrl(url)
}
analyticsEventHandler.send(
@ -747,7 +755,7 @@ internal class SwapModel @Inject constructor(
)
},
onStatusClick = {
val txExternalUrl = it.txExternalUrl
val txExternalUrl = swapTransactionState.txExternalUrl
if (!txExternalUrl.isNullOrBlank()) {
urlOpener.openUrl(txExternalUrl)
analyticsEventHandler.send(
@ -771,12 +779,15 @@ internal class SwapModel @Inject constructor(
startLoadingQuotesFromLastState()
uiState = stateBuilder.createErrorTransactionAlert(
uiState = uiState,
error = it,
error = swapTransactionState,
onDismiss = { uiState = stateBuilder.clearAlert(uiState) },
onSupportClick = ::onFailedTxEmailClick,
isReverseSwapPossible = isReverseSwapPossible(),
)
}
is SwapTransactionState.TangemPayWithdrawalData -> {
processTangemPayWithdrawal(swapTransactionState = swapTransactionState)
}
}
}.onFailure {
Timber.e(it)
@ -786,6 +797,40 @@ internal class SwapModel @Inject constructor(
}
}
private suspend fun processTangemPayWithdrawal(swapTransactionState: SwapTransactionState.TangemPayWithdrawalData) {
tangemPayWithdrawUseCase(
userWalletId = userWalletId,
cryptoAmount = swapTransactionState.cryptoAmount,
cryptoCurrencyId = swapTransactionState.cryptoCurrencyId,
receiverCexAddress = swapTransactionState.cexAddress,
)
.onLeft {
startLoadingQuotesFromLastState()
makeDefaultAlert()
}
.onRight {
swapInteractor.storeSwapTransaction(
currencyToSend = swapTransactionState.storeData.currencyToSend,
currencyToGet = swapTransactionState.storeData.currencyToGet,
fromAccount = swapTransactionState.storeData.fromAccount,
toAccount = swapTransactionState.storeData.toAccount,
amount = swapTransactionState.storeData.amount,
swapProvider = swapTransactionState.storeData.swapProvider,
swapDataModel = swapTransactionState.storeData.swapDataModel,
txExternalUrl = "",
timestamp = System.currentTimeMillis(),
txExternalId = swapTransactionState.storeData.txExternalId,
averageDuration = null,
)
uiState = stateBuilder.createTangemPayWithdrawalSuccessState(
uiState = uiState,
swapTransactionState = swapTransactionState,
dataState = dataState,
)
swapRouter.openScreen(SwapNavScreen.Success)
}
}
private fun sendSuccessEvent() {
val provider = dataState.selectedProvider ?: return
val fee = dataState.selectedFee?.feeType ?: return
@ -804,6 +849,7 @@ internal class SwapModel @Inject constructor(
)
}
@Suppress("LongMethod")
private fun givePermissionsToSwap() {
modelScope.launch(dispatchers.main) {
runCatching {
@ -840,8 +886,8 @@ internal class SwapModel @Inject constructor(
spenderAddress = approveDataModel.spenderAddress,
),
)
}.onSuccess {
when (it) {
}.onSuccess { swapTransactionState ->
when (swapTransactionState) {
is SwapTransactionState.TxSent -> {
sendApproveSuccessEvent(fromToken, feeForPermission.feeType, approveType)
updateWalletBalance()
@ -852,7 +898,7 @@ internal class SwapModel @Inject constructor(
is SwapTransactionState.Error -> {
uiState = stateBuilder.createErrorTransactionAlert(
uiState = uiState,
error = it,
error = swapTransactionState,
onDismiss = { uiState = stateBuilder.clearAlert(uiState) },
onSupportClick = ::onFailedTxEmailClick,
isReverseSwapPossible = isReverseSwapPossible(),
@ -865,6 +911,9 @@ internal class SwapModel @Inject constructor(
isReverseSwapPossible = isReverseSwapPossible(),
)
}
is SwapTransactionState.TangemPayWithdrawalData -> {
processTangemPayWithdrawal(swapTransactionState = swapTransactionState)
}
}
}.onFailure { makeDefaultAlert() }
}.onFailure {
@ -1475,7 +1524,17 @@ internal class SwapModel @Inject constructor(
} else {
groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value }
?.providers
}.orEmpty()
}
?.filterForTangemPayWithdrawal()
.orEmpty()
}
private fun List<SwapProvider>.filterForTangemPayWithdrawal(): List<SwapProvider> {
return if (tangemPayInput?.isWithdrawal == true) {
filter { it.type == ExchangeProviderType.CEX }
} else {
this
}
}
private fun Map<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {

View file

@ -92,12 +92,14 @@ internal class SwapNotificationsFactory(
return updatedNotifications.toPersistentList()
}
@Suppress("LongParameterList")
fun getConfirmationStateNotifications(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
selectedFeeType: FeeType,
providerName: String,
hideFee: Boolean,
): ImmutableList<NotificationUM> {
val warnings = buildList {
maybeAddRentExemptionError(quoteModel)
@ -105,7 +107,7 @@ internal class SwapNotificationsFactory(
maybeAddNeedReserveToCreateAccountWarning(quoteModel)
maybeAddPermissionNeededWarning(quoteModel, fromToken, providerName)
maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, hideFee)
maybeAddTransactionInProgressWarning(quoteModel)
}
return warnings.toPersistentList()
@ -284,7 +286,9 @@ internal class SwapNotificationsFactory(
private fun MutableList<NotificationUM>.maybeAddUnableCoverFeeWarning(
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
hideFee: Boolean,
) {
if (hideFee) return
val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough ?: return
val needShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.permissionState !is PermissionDataState.PermissionLoading &&

View file

@ -277,6 +277,7 @@ internal class StateBuilder(
selectedFeeType: FeeType,
isReverseSwapPossible: Boolean,
needApplyFCARestrictions: Boolean,
hideFee: Boolean,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
@ -286,8 +287,9 @@ internal class StateBuilder(
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
selectedFeeType = selectedFeeType,
providerName = swapProvider.name,
hideFee = hideFee,
)
val feeState = createFeeState(quoteModel.txFee, selectedFeeType)
val feeState = if (hideFee) FeeItemState.Empty else createFeeState(quoteModel.txFee, selectedFeeType)
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus
val isInsufficientFunds = isInsufficientFundsCondition(quoteModel)
@ -853,6 +855,45 @@ internal class StateBuilder(
)
}
fun createTangemPayWithdrawalSuccessState(
uiState: SwapStateHolder,
swapTransactionState: SwapTransactionState.TangemPayWithdrawalData,
dataState: SwapProcessDataState,
): SwapStateHolder {
val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency)
val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency)
val fromAmount = swapTransactionState.fromAmountValue ?: BigDecimal.ZERO
val toAmount = swapTransactionState.toAmountValue ?: BigDecimal.ZERO
val providerState = uiState.providerState as ProviderState.Content
val fromFiatAmount = getFormattedFiatAmount(fromCryptoCurrency.value.fiatRate?.multiply(fromAmount))
val toFiatAmount = getFormattedFiatAmount(toCryptoCurrency.value.fiatRate?.multiply(toAmount))
val shouldShowStatus = providerState.type == ExchangeProviderType.CEX.providerName
return uiState.copy(
successState = SwapSuccessStateHolder(
timestamp = System.currentTimeMillis(),
txUrl = "",
providerName = stringReference(providerState.name),
providerType = stringReference(providerState.type),
showStatusButton = shouldShowStatus,
providerIcon = providerState.iconUrl,
rate = providerState.subtitle,
fee = TextReference.EMPTY,
fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount),
toTitle = getToCardAccountTitle(toAccount = dataState.toAccount),
fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()),
toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()),
fromTokenFiatAmount = stringReference(fromFiatAmount),
toTokenFiatAmount = stringReference(toFiatAmount),
fromTokenIconState = iconStateConverter.convert(fromCryptoCurrency),
toTokenIconState = iconStateConverter.convert(toCryptoCurrency),
onExploreButtonClick = {},
onStatusButtonClick = {},
),
)
}
fun createErrorTransactionAlert(
uiState: SwapStateHolder,
error: SwapTransactionState.Error,

View file

@ -1,5 +1,5 @@
package com.tangem.features.tangempay.entity
internal enum class TangemPayDetailsErrorType {
Receive,
Receive, Withdraw, WithdrawInProgress
}

View file

@ -73,9 +73,14 @@ internal class TangemPayDetailsStateFactory(
actionButtons = persistentListOf(
ActionButtonConfig(
text = resourceReference(id = R.string.tangempay_card_details_add_funds),
iconResId = R.drawable.ic_arrow_down_24,
iconResId = R.drawable.ic_plus_24,
onClick = intents::onClickAddFunds,
),
ActionButtonConfig(
text = resourceReference(id = R.string.tangempay_card_details_withdraw),
iconResId = R.drawable.ic_arrow_up_24,
onClick = intents::onClickWithdraw,
),
),
),
addToWalletBlockState = null,

View file

@ -4,7 +4,7 @@ import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.pay.TangemPayTopUpDataFactory
import com.tangem.domain.pay.TangemPaySwapDataFactory
import com.tangem.features.tangempay.components.TangemPayAddFundsComponent
import com.tangem.features.tangempay.entity.TangemPayAddFundsUM
import com.tangem.features.tangempay.model.transformers.TangemPayAddFundsUMConverter
@ -16,7 +16,7 @@ import javax.inject.Inject
internal class TangemPayAddFundsModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val tangemPayTopUpDataFactory: TangemPayTopUpDataFactory,
private val tangemPaySwapDataFactory: TangemPaySwapDataFactory,
) : Model() {
private val params = paramsContainer.require<TangemPayAddFundsComponent.Params>()
@ -24,7 +24,7 @@ internal class TangemPayAddFundsModel @Inject constructor(
val uiState: TangemPayAddFundsUM = getInitialState()
private fun getInitialState(): TangemPayAddFundsUM {
val data = tangemPayTopUpDataFactory.create(
val data = tangemPaySwapDataFactory.create(
depositAddress = params.depositAddress,
chainId = params.chainId,
cryptoBalance = params.cryptoBalance,

View file

@ -18,7 +18,9 @@ import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.pay.TangemPayTopUpData
import com.tangem.domain.pay.TangemPaySwapDataFactory
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
@ -64,6 +66,8 @@ internal class TangemPayDetailsModel @Inject constructor(
private val uiMessageSender: UiMessageSender,
private val cardDetailsEventListener: CardDetailsEventListener,
private val txHistoryUpdateListener: TangemPayTxHistoryUpdateListener,
private val tangemPaySwapDataFactory: TangemPaySwapDataFactory,
private val orderRepository: CustomerOrderRepository,
) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener {
private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require()
@ -199,22 +203,63 @@ internal class TangemPayDetailsModel @Inject constructor(
}
override fun onClickAddFunds() {
if (params.config.depositAddress == null || balance == null) {
val currentBalance = balance
val depositAddress = params.config.depositAddress
if (currentBalance == null || depositAddress == null) {
showBottomSheetError(TangemPayDetailsErrorType.Receive)
} else {
bottomSheetNavigation.activate(
TangemPayDetailsNavigation.AddFunds(
walletId = params.userWalletId,
fiatBalance = requireNotNull(balance).balance,
// Using the fiat balance as crypto balance. This will be changed when back returns crypto balance
cryptoBalance = requireNotNull(balance).balance,
depositAddress = requireNotNull(params.config.depositAddress),
fiatBalance = currentBalance.fiatBalance,
cryptoBalance = currentBalance.cryptoBalance,
depositAddress = depositAddress,
chainId = params.config.chainId,
),
)
}
}
override fun onClickWithdraw() {
val currentBalance = balance
val depositAddress = params.config.depositAddress
if (currentBalance == null || depositAddress == null) {
showBottomSheetError(TangemPayDetailsErrorType.Withdraw)
} else {
modelScope.launch {
val hasActiveWithdrawal = orderRepository.hasWithdrawOrder(userWalletId = params.userWalletId)
if (hasActiveWithdrawal) {
showBottomSheetError(TangemPayDetailsErrorType.WithdrawInProgress)
} else {
val data = tangemPaySwapDataFactory.create(
depositAddress = depositAddress,
chainId = params.config.chainId,
cryptoBalance = currentBalance.cryptoBalance,
fiatBalance = currentBalance.fiatBalance,
).getOrNull()
if (data != null) {
router.push(
AppRoute.Swap(
currencyFrom = data.currency,
userWalletId = data.walletId,
isInitialReverseOrder = false,
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
tangemPayInput = AppRoute.Swap.TangemPayInput(
cryptoAmount = data.cryptoBalance,
fiatAmount = data.fiatBalance,
depositAddress = data.depositAddress,
isWithdrawal = true,
),
),
)
} else {
showBottomSheetError(TangemPayDetailsErrorType.Withdraw)
}
}
}
}
}
private fun fetchBalance(): Job {
return modelScope.launch {
val result = try {
@ -294,6 +339,7 @@ internal class TangemPayDetailsModel @Inject constructor(
cryptoAmount = data.cryptoBalance,
fiatAmount = data.fiatBalance,
depositAddress = data.depositAddress,
isWithdrawal = false,
),
),
)

View file

@ -35,7 +35,7 @@ internal class DetailsBalanceTransformer(
private fun getBalanceText(balance: TangemPayCardBalance): String {
val currency = Currency.getInstance(balance.currencyCode)
return balance.balance.format {
return balance.fiatBalance.format {
fiat(fiatCurrencyCode = currency.currencyCode, fiatCurrencySymbol = currency.symbol)
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi
internal interface TangemPayDetailIntents {
fun onRefreshSwipe(refreshState: ShowRefreshState)
fun onClickAddFunds()
fun onClickWithdraw()
fun onClickChangePin()
fun onClickFreezeCard()
fun onClickUnfreezeCard()

View file

@ -25,6 +25,32 @@ internal object TangemPayMessagesFactory {
onClick { closeBs() }
}
}
TangemPayDetailsErrorType.Withdraw -> bottomSheetMessage {
infoBlock {
icon(R.drawable.img_attention_20) {
backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Attention
}
title = TextReference.Res(R.string.tangempay_card_details_withdraw_error_title)
body = TextReference.Res(R.string.tangempay_card_details_receive_error_description)
}
primaryButton {
text = resourceReference(R.string.common_got_it)
onClick { closeBs() }
}
}
TangemPayDetailsErrorType.WithdrawInProgress -> bottomSheetMessage {
infoBlock {
icon(R.drawable.ic_clock_24) {
backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Informative
}
title = TextReference.Res(R.string.tangempay_card_details_withdraw_in_progress_title)
body = TextReference.Res(R.string.tangempay_card_details_withdraw_in_progress_description)
}
secondaryButton {
text = resourceReference(R.string.common_got_it)
onClick { closeBs() }
}
}
}
}

View file

@ -160,5 +160,7 @@ interface TangemSdkManager {
): CompletionResult<VisaSignedDataByCustomerWallet>
suspend fun tangemPayProduceInitialCredentials(cardId: String): CompletionResult<TangemPayInitialCredentials>
suspend fun getWithdrawalSignature(cardId: String, hash: String): CompletionResult<String>
// endregion
}