Updated on 2026-08-14
This commit is contained in:
commit
e4754fed3b
483 changed files with 9315 additions and 4084 deletions
|
|
@ -13,6 +13,8 @@ import com.tangem.domain.models.account.*
|
|||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.extensions.addOrReplace
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
|
|
@ -37,23 +39,52 @@ internal class DefaultAccountsCRUDRepository(
|
|||
}
|
||||
|
||||
override suspend fun getArchivedAccount(accountId: AccountId): Option<ArchivedAccount> = option {
|
||||
ArchivedAccount(
|
||||
accountId = accountId,
|
||||
name = AccountName("Archived Account").getOrNull()!!,
|
||||
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
|
||||
derivationIndex = DerivationIndex(value = 1000).getOrNull()!!,
|
||||
tokensCount = 2,
|
||||
networksCount = 1,
|
||||
createMockArchivedAccount(userWalletId = accountId.userWalletId)
|
||||
}
|
||||
|
||||
override suspend fun getArchivedAccountsSync(userWalletId: UserWalletId): Option<List<ArchivedAccount>> = option {
|
||||
listOf(
|
||||
createMockArchivedAccount(userWalletId),
|
||||
)
|
||||
}
|
||||
|
||||
override fun getArchivedAccounts(userWalletId: UserWalletId): Flow<List<ArchivedAccount>> {
|
||||
return flow {
|
||||
getArchivedAccountsSync(userWalletId).getOrNull().orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) = Unit
|
||||
|
||||
override suspend fun saveAccounts(accountList: AccountList) {
|
||||
runtimeStore.update(emptyList()) {
|
||||
it.addOrReplace(accountList) { it.userWallet.walletId == accountList.userWallet.walletId }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Int {
|
||||
val activeAccountsCount = runtimeStore.getSyncOrNull()?.size ?: 1
|
||||
|
||||
return activeAccountsCount + 1
|
||||
}
|
||||
|
||||
override fun getUserWallet(userWalletId: UserWalletId): UserWallet {
|
||||
return userWalletsStore.getSyncStrict(userWalletId)
|
||||
}
|
||||
|
||||
private fun createMockArchivedAccount(userWalletId: UserWalletId): ArchivedAccount {
|
||||
val derivationIndex = DerivationIndex(value = 1000).getOrNull()!!
|
||||
|
||||
return ArchivedAccount(
|
||||
accountId = AccountId.forCryptoPortfolio(
|
||||
userWalletId = userWalletId,
|
||||
derivationIndex = derivationIndex,
|
||||
),
|
||||
name = AccountName("Archived Account").getOrNull()!!,
|
||||
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
|
||||
derivationIndex = derivationIndex,
|
||||
tokensCount = 2,
|
||||
networksCount = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ package com.tangem.data.blockaid
|
|||
import com.domain.blockaid.models.dapp.CheckDAppResult
|
||||
import com.domain.blockaid.models.transaction.*
|
||||
import com.domain.blockaid.models.transaction.simultation.AmountInfo
|
||||
import com.domain.blockaid.models.transaction.simultation.ApprovedAmount
|
||||
import com.domain.blockaid.models.transaction.simultation.ApproveInfo
|
||||
import com.domain.blockaid.models.transaction.simultation.SimulationData
|
||||
import com.domain.blockaid.models.transaction.simultation.TokenInfo
|
||||
import com.tangem.blockchain.extensions.hexToBigDecimal
|
||||
|
|
@ -17,6 +17,9 @@ private const val SUCCESS_STATUS = "Success"
|
|||
private const val DOMAIN_CHECKED_STATUS = "hit"
|
||||
private const val VALIDATION_SAFE_STATUS = "Benign"
|
||||
private const val VALIDATION_WARNING_STATUS = "Warning"
|
||||
private const val VALIDATION_MALICIOUS_STATUS = "Malicious"
|
||||
|
||||
private const val SOL_ASSET_SYMBOL = "SOL"
|
||||
|
||||
internal object BlockAidMapper {
|
||||
|
||||
|
|
@ -28,6 +31,26 @@ internal object BlockAidMapper {
|
|||
}
|
||||
}
|
||||
|
||||
fun mapToDomain(from: SolanaTransactionResponse): CheckTransactionResult {
|
||||
val validation = when (from.result.validation.resultType) {
|
||||
VALIDATION_SAFE_STATUS -> ValidationResult.SAFE
|
||||
VALIDATION_WARNING_STATUS -> ValidationResult.WARNING
|
||||
VALIDATION_MALICIOUS_STATUS -> ValidationResult.UNSAFE
|
||||
else -> ValidationResult.FAILED_TO_VALIDATE
|
||||
}
|
||||
val simulationResponse = from.result.simulation
|
||||
val simulation = if (simulationResponse == null) {
|
||||
SimulationResult.FailedToSimulate
|
||||
} else {
|
||||
mapToSolanaAssetsDiffs(simulationResponse.accountSummary.accountAssetsDiff)
|
||||
}
|
||||
return CheckTransactionResult(
|
||||
validation = validation,
|
||||
description = from.result.validation.description,
|
||||
simulation = simulation,
|
||||
)
|
||||
}
|
||||
|
||||
fun mapToDomain(from: TransactionScanResponse): CheckTransactionResult {
|
||||
return CheckTransactionResult(
|
||||
validation = when {
|
||||
|
|
@ -68,7 +91,7 @@ internal object BlockAidMapper {
|
|||
|
||||
fun mapToSolanaRequest(from: TransactionData): SolanaTransactionScanRequest {
|
||||
return SolanaTransactionScanRequest(
|
||||
chain = from.chain.lowercase(),
|
||||
blockchain = from.chain.lowercase(),
|
||||
accountAddress = from.accountAddress,
|
||||
metadata = TransactionMetadata(from.domainUrl),
|
||||
method = from.method,
|
||||
|
|
@ -78,35 +101,61 @@ internal object BlockAidMapper {
|
|||
|
||||
private fun mapSimulationSuccessResult(from: AccountSummaryResponse): SimulationResult {
|
||||
return when {
|
||||
!from.assetsDiffs.isNullOrEmpty() -> mapSendReceiveTransaction(
|
||||
from.assetsDiffs,
|
||||
)
|
||||
!from.exposures.isNullOrEmpty() -> mapApproveTransaction(
|
||||
from.exposures,
|
||||
)
|
||||
!from.traces.isNullOrEmpty() -> mapNftSendReceiveTransaction(from.traces)
|
||||
!from.exposures.isNullOrEmpty() -> mapApproveTransaction(from.exposures)
|
||||
!from.assetsDiffs.isNullOrEmpty() -> mapSendReceiveTransaction(from.assetsDiffs)
|
||||
else -> SimulationResult.Success(data = SimulationData.NoWalletChangesDetected)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapApproveTransaction(exposures: List<Exposure>?): SimulationResult {
|
||||
val amounts = exposures?.flatMap { exposure ->
|
||||
val tokenInfo = TokenInfo(
|
||||
chainId = exposure.asset.chainId,
|
||||
logoUrl = exposure.asset.logoUrl,
|
||||
symbol = exposure.asset.symbol ?: "",
|
||||
decimals = exposure.asset.decimals ?: 0,
|
||||
private fun mapToSolanaAssetsDiffs(assetsDiffs: List<SolanaTransactionAssetDiff>): SimulationResult {
|
||||
val sendInfo = assetsDiffs.mapNotNull { assetDiff ->
|
||||
val outTransfer = assetDiff.outTransfer ?: return@mapNotNull null
|
||||
val amount = outTransfer.amount?.toBigDecimalOrNull() ?: return@mapNotNull null
|
||||
AmountInfo.FungibleTokens(
|
||||
amount = amount,
|
||||
token = TokenInfo(
|
||||
chainId = null,
|
||||
logoUrl = assetDiff.asset.logoUrl,
|
||||
symbol = assetDiff.asset.assetSymbol(),
|
||||
decimals = assetDiff.asset.decimals ?: 0,
|
||||
),
|
||||
)
|
||||
exposure.spenders.flatMap { (_, spender) ->
|
||||
val isUnlimited = spender.isApprovedForAll == true
|
||||
val approval = spender.approval?.hexToBigDecimal()
|
||||
spender.exposure.map { detail ->
|
||||
ApprovedAmount(
|
||||
approvedAmount = detail.value?.toBigDecimalOrNull() ?: approval ?: 1.toBigDecimal(),
|
||||
isUnlimited = isUnlimited,
|
||||
tokenInfo = tokenInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
val receiveInfo = assetsDiffs.mapNotNull { assetDiff ->
|
||||
val inTransfer = assetDiff.inTransfer ?: return@mapNotNull null
|
||||
val amount = inTransfer.amount?.toBigDecimalOrNull() ?: return@mapNotNull null
|
||||
AmountInfo.FungibleTokens(
|
||||
amount = amount,
|
||||
token = TokenInfo(
|
||||
chainId = null,
|
||||
logoUrl = assetDiff.asset.logoUrl,
|
||||
symbol = assetDiff.asset.assetSymbol(),
|
||||
decimals = assetDiff.asset.decimals ?: 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return if (sendInfo.isNotEmpty() || receiveInfo.isNotEmpty()) {
|
||||
SimulationResult.Success(SimulationData.SendAndReceive(send = sendInfo, receive = receiveInfo))
|
||||
} else {
|
||||
SimulationResult.Success(SimulationData.NoWalletChangesDetected)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SolanaTransactionAsset.assetSymbol(): String {
|
||||
return if (type?.lowercase().equals(SOL_ASSET_SYMBOL, ignoreCase = true)) {
|
||||
symbol ?: SOL_ASSET_SYMBOL
|
||||
} else {
|
||||
symbol.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapApproveTransaction(exposures: List<Exposure>?): SimulationResult {
|
||||
val amounts: List<ApproveInfo>? = exposures?.flatMap { exposure ->
|
||||
if (exposure.assetType.isNFT()) {
|
||||
listOf(mapApproveNftTransaction(exposure))
|
||||
} else {
|
||||
mapTransaction(exposure)
|
||||
}
|
||||
}
|
||||
return if (!amounts.isNullOrEmpty()) {
|
||||
|
|
@ -116,6 +165,34 @@ internal object BlockAidMapper {
|
|||
}
|
||||
}
|
||||
|
||||
private fun mapTransaction(exposure: Exposure): List<ApproveInfo.Amount> {
|
||||
val tokenInfo = TokenInfo(
|
||||
chainId = exposure.asset.chainId,
|
||||
logoUrl = exposure.asset.logoUrl,
|
||||
symbol = exposure.asset.symbol ?: "",
|
||||
decimals = exposure.asset.decimals ?: 0,
|
||||
)
|
||||
return exposure.spenders.flatMap { (_, spender) ->
|
||||
val isUnlimited = spender.isApprovedForAll == true
|
||||
val approval = spender.approval?.hexToBigDecimal()
|
||||
spender.exposure.map { detail ->
|
||||
ApproveInfo.Amount(
|
||||
approvedAmount = detail.value?.toBigDecimalOrNull() ?: approval ?: 1.toBigDecimal(),
|
||||
isUnlimited = isUnlimited,
|
||||
tokenInfo = tokenInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapApproveNftTransaction(exposure: Exposure): ApproveInfo.NonFungibleToken {
|
||||
return ApproveInfo.NonFungibleToken(
|
||||
name = exposure.asset.name.orEmpty(),
|
||||
logoUrl = exposure.spenders.values.firstOrNull()?.exposure?.firstOrNull()?.logoUrl
|
||||
?: exposure.asset.logoUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapSendReceiveTransaction(assetDiffs: List<AssetDiff>?): SimulationResult {
|
||||
val sendInfo = arrayListOf<AmountInfo>()
|
||||
val receiveInfo = arrayListOf<AmountInfo>()
|
||||
|
|
@ -128,13 +205,31 @@ internal object BlockAidMapper {
|
|||
decimals = diff.asset.decimals ?: 0,
|
||||
)
|
||||
diff.outTransfer.orEmpty().forEach { transfer ->
|
||||
transfer.value?.toBigDecimalOrNull()?.let { amount ->
|
||||
sendInfo.add(AmountInfo.FungibleTokens(amount = amount, token = token))
|
||||
if (diff.assetType.isNFT()) {
|
||||
sendInfo.add(
|
||||
AmountInfo.NonFungibleTokens(
|
||||
name = diff.asset.name.orEmpty(),
|
||||
logoUrl = token.logoUrl,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
transfer.value?.toBigDecimalOrNull()?.let { amount ->
|
||||
sendInfo.add(AmountInfo.FungibleTokens(amount = amount, token = token))
|
||||
}
|
||||
}
|
||||
}
|
||||
diff.inTransfer.orEmpty().forEach { transfer ->
|
||||
transfer.value?.toBigDecimalOrNull()?.let { amount ->
|
||||
receiveInfo.add(AmountInfo.FungibleTokens(amount = amount, token = token))
|
||||
if (diff.assetType.isNFT()) {
|
||||
receiveInfo.add(
|
||||
AmountInfo.NonFungibleTokens(
|
||||
name = diff.asset.name.orEmpty(),
|
||||
logoUrl = token.logoUrl,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
transfer.value?.toBigDecimalOrNull()?.let { amount ->
|
||||
receiveInfo.add(AmountInfo.FungibleTokens(amount = amount, token = token))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -146,17 +241,7 @@ internal object BlockAidMapper {
|
|||
}
|
||||
}
|
||||
|
||||
private fun mapNftSendReceiveTransaction(traces: List<Trace>?): SimulationResult {
|
||||
val sendInfo = traces?.mapNotNull {
|
||||
it.exposed?.let { exposed ->
|
||||
AmountInfo.NonFungibleTokens(name = "${it.asset.name} #${exposed.tokenId}", logoUrl = exposed.logoUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return if (!sendInfo.isNullOrEmpty()) {
|
||||
SimulationResult.Success(SimulationData.SendAndReceive(send = sendInfo, receive = listOf()))
|
||||
} else {
|
||||
SimulationResult.Success(SimulationData.NoWalletChangesDetected)
|
||||
}
|
||||
private fun String.isNFT(): Boolean {
|
||||
return this.lowercase() == "erc721" || this.lowercase() == "erc1155" || this.lowercase() == "nft"
|
||||
}
|
||||
}
|
||||
|
|
@ -24,16 +24,21 @@ internal class DefaultBlockAidRepository(
|
|||
}
|
||||
|
||||
override suspend fun verifyTransaction(data: TransactionData): CheckTransactionResult {
|
||||
val response = withContext(dispatchers.io) {
|
||||
when (data.params) {
|
||||
is TransactionParams.Evm -> {
|
||||
api.scanJsonRpc(mapper.mapToEvmRequest(data))
|
||||
}
|
||||
is TransactionParams.Solana -> {
|
||||
api.scanSolanaMessage(mapper.mapToSolanaRequest(data))
|
||||
}
|
||||
}
|
||||
return when (data.params) {
|
||||
is TransactionParams.Evm -> scanEvmTransaction(data = data)
|
||||
is TransactionParams.Solana -> scanSolanaTransaction(data = data)
|
||||
}
|
||||
return mapper.mapToDomain(response)
|
||||
}
|
||||
|
||||
private suspend fun scanEvmTransaction(data: TransactionData): CheckTransactionResult =
|
||||
withContext(dispatchers.io) {
|
||||
val response = api.scanJsonRpc(mapper.mapToEvmRequest(data))
|
||||
mapper.mapToDomain(response)
|
||||
}
|
||||
|
||||
private suspend fun scanSolanaTransaction(data: TransactionData): CheckTransactionResult =
|
||||
withContext(dispatchers.io) {
|
||||
val response = api.scanSolanaMessage(mapper.mapToSolanaRequest(data))
|
||||
mapper.mapToDomain(response)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import com.domain.blockaid.models.dapp.CheckDAppResult
|
|||
import com.domain.blockaid.models.transaction.SimulationResult
|
||||
import com.domain.blockaid.models.transaction.ValidationResult
|
||||
import com.domain.blockaid.models.transaction.simultation.AmountInfo
|
||||
import com.domain.blockaid.models.transaction.simultation.ApproveInfo
|
||||
import com.domain.blockaid.models.transaction.simultation.SimulationData
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.datasource.api.common.blockaid.models.response.*
|
||||
|
|
@ -44,6 +45,7 @@ class BlockAidMapperTest {
|
|||
val exposure = Exposure(
|
||||
asset = Asset(chainId = 1, logoUrl = "logo", symbol = "PEPE", decimals = 8),
|
||||
spenders = mapOf("spender" to spenderDetails),
|
||||
assetType = "native",
|
||||
)
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign", description = ""),
|
||||
|
|
@ -65,9 +67,10 @@ class BlockAidMapperTest {
|
|||
|
||||
val approve = simulation?.data as? SimulationData.Approve
|
||||
Truth.assertThat(approve).isNotNull()
|
||||
Truth.assertThat(approve?.approvedAmounts?.size).isEqualTo(1)
|
||||
Truth.assertThat(approve?.approvedAmounts?.first()?.approvedAmount).isEqualTo(BigDecimal("1000.0"))
|
||||
Truth.assertThat(approve?.approvedAmounts?.first()?.isUnlimited).isTrue()
|
||||
Truth.assertThat(approve?.items?.size).isEqualTo(1)
|
||||
Truth.assertThat((approve?.items?.first() as? ApproveInfo.Amount)?.approvedAmount)
|
||||
.isEqualTo(BigDecimal("1000.0"))
|
||||
Truth.assertThat((approve?.items?.first() as? ApproveInfo.Amount)?.isUnlimited).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.tangem.datasource.api.common.blockaid.models.request.DomainScanReques
|
|||
import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionScanRequest
|
||||
import com.tangem.datasource.api.common.blockaid.models.request.SolanaTransactionScanRequest
|
||||
import com.tangem.datasource.api.common.blockaid.models.response.DomainScanResponse
|
||||
import com.tangem.datasource.api.common.blockaid.models.response.SolanaTransactionResponse
|
||||
import com.tangem.datasource.api.common.blockaid.models.response.TransactionScanResponse
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
|
|
@ -91,7 +92,7 @@ class DefaultBlockAidRepositoryTest {
|
|||
)
|
||||
|
||||
val request = mockk<SolanaTransactionScanRequest>()
|
||||
val response = mockk<TransactionScanResponse>()
|
||||
val response = mockk<SolanaTransactionResponse>()
|
||||
val expectedResult = mockk<CheckTransactionResult>()
|
||||
|
||||
every { mapper.mapToSolanaRequest(data) } returns request
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.tangem.blockchainsdk.utils.toNetworkId
|
|||
import com.tangem.data.common.api.safeApiCall
|
||||
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
|
||||
import com.tangem.data.common.currency.UserTokensResponseFactory
|
||||
import com.tangem.data.common.currency.UserTokensSaver
|
||||
import com.tangem.data.common.network.NetworkFactory
|
||||
import com.tangem.data.common.utils.retryOnError
|
||||
import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher
|
||||
|
|
@ -42,6 +43,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
|||
internal class DefaultManageTokensRepository(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val userWalletsStore: UserWalletsStore,
|
||||
private val userTokenSaver: UserTokensSaver,
|
||||
private val manageTokensUpdateFetcher: ManageTokensUpdateFetcher,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val testnetTokensStorage: TestnetTokensStorage,
|
||||
|
|
@ -127,7 +129,8 @@ internal class DefaultManageTokensRepository(
|
|||
val tokensResponse = request.params.userWalletId?.let { userWalletId ->
|
||||
if (loadUserTokensFromRemote && userWallet != null) {
|
||||
safeApiCall({ tangemTechApi.getUserTokens(userWalletId.stringValue).bind() }) {
|
||||
createDefaultUserTokensResponse(userWallet)
|
||||
// save tokens response only if loadUserTokensFromRemote is true and it means onboarding call
|
||||
createAndSaveDefaultUserTokensResponse(userWallet = userWallet)
|
||||
}
|
||||
} else {
|
||||
getSavedUserTokensResponseSync(userWalletId)
|
||||
|
|
@ -158,6 +161,12 @@ internal class DefaultManageTokensRepository(
|
|||
)
|
||||
}
|
||||
|
||||
private suspend fun createAndSaveDefaultUserTokensResponse(userWallet: UserWallet): UserTokensResponse {
|
||||
val userTokensResponse = createDefaultUserTokensResponse(userWallet)
|
||||
userTokenSaver.store(userWallet.walletId, userTokensResponse, useEnricher = false)
|
||||
return userTokensResponse
|
||||
}
|
||||
|
||||
private suspend fun fetchTestnetCurrencies(
|
||||
userWallet: UserWallet,
|
||||
request: Request<ManageTokensListConfig>,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ internal object ManageTokensDataModule {
|
|||
userWalletsStore: UserWalletsStore,
|
||||
manageTokensUpdateFetcher: ManageTokensUpdateFetcher,
|
||||
userTokensResponseStore: UserTokensResponseStore,
|
||||
userTokensSaver: UserTokensSaver,
|
||||
testnetTokensStorage: TestnetTokensStorage,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
|
|
@ -43,6 +44,7 @@ internal object ManageTokensDataModule {
|
|||
userWalletsStore = userWalletsStore,
|
||||
manageTokensUpdateFetcher = manageTokensUpdateFetcher,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
userTokenSaver = userTokensSaver,
|
||||
testnetTokensStorage = testnetTokensStorage,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
|
||||
|
|
|
|||
|
|
@ -381,6 +381,26 @@ internal class DefaultTransactionRepository(
|
|||
preparer.prepareForSendMultiple(transactionData, signer)
|
||||
}
|
||||
|
||||
override suspend fun prepareAndSign(
|
||||
transactionData: TransactionData,
|
||||
signer: TransactionSigner,
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
) = withContext(dispatchers.io) {
|
||||
val preparer = getPreparer(network, userWalletId)
|
||||
preparer.prepareAndSign(transactionData, signer)
|
||||
}
|
||||
|
||||
override suspend fun prepareAndSignMultiple(
|
||||
transactionData: List<TransactionData>,
|
||||
signer: TransactionSigner,
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
) = withContext(dispatchers.io) {
|
||||
val preparer = getPreparer(network, userWalletId)
|
||||
preparer.prepareAndSignMultiple(transactionData, signer)
|
||||
}
|
||||
|
||||
private suspend fun getPreparer(network: Network, userWalletId: UserWalletId): TransactionPreparer {
|
||||
val blockchain = network.toBlockchain()
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ dependencies {
|
|||
/** Libs - Tangem */
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(projects.libs.tangemSdkApi)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.core)
|
||||
|
|
|
|||
|
|
@ -2,39 +2,52 @@ package com.tangem.data.pay
|
|||
|
||||
import arrow.core.Either
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.common.map
|
||||
import com.tangem.core.error.UniversalError
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.pay.TangemPayApi
|
||||
import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.KycStartInfo
|
||||
import com.tangem.domain.pay.repository.KycRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.assisted.Assisted
|
||||
import com.tangem.domain.visa.model.VisaDataForApprove
|
||||
import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet
|
||||
import com.tangem.domain.visa.repository.VisaAuthRepository
|
||||
import com.tangem.sdk.api.TangemSdkManager
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Suppress("UnusedPrivateMember")
|
||||
class DefaultKycRepository @AssistedInject constructor(
|
||||
@Assisted userWalletId: UserWalletId,
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
private val tangemPayApi: TangemPayApi,
|
||||
private val dispatcherProvider: CoroutineDispatcherProvider,
|
||||
private val visaAuthRepository: VisaAuthRepository,
|
||||
private val tangemSdkManager: TangemSdkManager,
|
||||
) : KycRepository {
|
||||
|
||||
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi)
|
||||
|
||||
override suspend fun getKycStartInfo(): Either<UniversalError, KycStartInfo> = withContext(dispatcherProvider.io) {
|
||||
val authTokenForSpecificWallet = "get from userWalletId"
|
||||
|
||||
request {
|
||||
tangemPayApi.getKycAccess(
|
||||
authHeader = authTokenForSpecificWallet,
|
||||
).getOrThrow().result
|
||||
override suspend fun getKycStartInfo(address: String, cardId: String): Either<UniversalError, KycStartInfo> {
|
||||
var authHeader = ""
|
||||
visaAuthRepository.getCustomerWalletAuthChallenge(address).getOrNull()?.let { result ->
|
||||
tangemSdkManager.visaCustomerWalletApprove(
|
||||
VisaDataForApprove(
|
||||
customerWalletCardId = cardId,
|
||||
targetAddress = address,
|
||||
dataToSign = VisaDataToSignByCustomerWallet(hashToSign = result.challenge),
|
||||
),
|
||||
).map { signResult ->
|
||||
visaAuthRepository.getTokenWithCustomerWallet(
|
||||
sessionId = result.session.sessionId,
|
||||
signature = signResult.signature,
|
||||
nonce = signResult.dataToSign.hashToSign,
|
||||
).getOrNull()?.let { authHeader = it }
|
||||
}
|
||||
}
|
||||
return request {
|
||||
authHeader.ifEmpty { error("Cannot get auth header for KYC") }
|
||||
tangemPayApi.getKycAccess(authHeader = authHeader).getOrThrow().result
|
||||
}.map {
|
||||
KycStartInfo(
|
||||
token = it.token,
|
||||
|
|
@ -65,6 +78,6 @@ class DefaultKycRepository @AssistedInject constructor(
|
|||
|
||||
@AssistedFactory
|
||||
interface Factory : KycRepository.Factory {
|
||||
override fun create(userWalletId: UserWalletId): DefaultKycRepository
|
||||
override fun create(): DefaultKycRepository
|
||||
}
|
||||
}
|
||||
|
|
@ -131,16 +131,18 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
|
|||
val authTokens =
|
||||
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
|
||||
|
||||
visaApi.activateByCustomerWallet(
|
||||
authHeader = authTokens.getAuthHeader(),
|
||||
body = ActivationByCustomerWalletRequest(
|
||||
orderId = signedData.dataToSign.request.orderId,
|
||||
customerWallet = ActivationByCustomerWalletRequest.CustomerWallet(
|
||||
deployAcceptanceSignature = signedData.signature,
|
||||
customerWalletAddress = signedData.customerWalletAddress,
|
||||
signedData.dataToSign.request?.orderId?.let { orderId ->
|
||||
visaApi.activateByCustomerWallet(
|
||||
authHeader = authTokens.getAuthHeader(),
|
||||
body = ActivationByCustomerWalletRequest(
|
||||
orderId = orderId,
|
||||
customerWallet = ActivationByCustomerWalletRequest.CustomerWallet(
|
||||
deployAcceptanceSignature = signedData.signature,
|
||||
customerWalletAddress = signedData.customerWalletAddress,
|
||||
),
|
||||
),
|
||||
),
|
||||
).getOrThrow()
|
||||
).getOrThrow()
|
||||
} ?: error("Order Id cannot be null")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,39 @@ internal class DefaultVisaAuthRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun getCustomerWalletAuthChallenge(
|
||||
customerWalletAddress: String,
|
||||
): Either<VisaApiError, VisaAuthChallenge.Wallet> = withContext(dispatchers.io) {
|
||||
request {
|
||||
visaAuthApi.generateNonceByCustomerWallet(
|
||||
GenerateNonceByCustomerWalletRequest(customerWalletAddress = customerWalletAddress),
|
||||
).getOrThrow()
|
||||
}.map { response ->
|
||||
VisaAuthChallenge.Wallet(
|
||||
challenge = response.result.nonce,
|
||||
session = VisaAuthSession(response.result.sessionId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getTokenWithCustomerWallet(
|
||||
sessionId: String,
|
||||
signature: String,
|
||||
nonce: String,
|
||||
): Either<VisaApiError, String> = withContext(dispatchers.io) {
|
||||
request {
|
||||
visaAuthApi.getTokenByCustomerWallet(
|
||||
GetTokenByCustomerWalletRequest(
|
||||
sessionId = sessionId,
|
||||
signature = signature,
|
||||
messageFormat = "Tangem Pay wants to sign in with your account. Nonce: $nonce",
|
||||
),
|
||||
).getOrThrow()
|
||||
}.map { response ->
|
||||
"Bearer ${response.result.accessToken}"
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAccessTokens(
|
||||
signedChallenge: VisaAuthSignedChallenge,
|
||||
): Either<VisaApiError, VisaAuthTokens> = withContext(dispatchers.io) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
package com.tangem.data.walletconnect.network.ethereum
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.reown.walletkit.client.Wallet
|
||||
import com.reown.walletkit.client.Wallet.Model
|
||||
import com.reown.walletkit.client.WalletKit
|
||||
import com.tangem.blockchain.extensions.hexToInt
|
||||
import com.tangem.data.walletconnect.model.CAIP10
|
||||
import com.tangem.data.walletconnect.model.CAIP2
|
||||
import com.tangem.data.walletconnect.network.ethereum.WcEthNetwork.NamespaceConverter.Companion.ETH_NAMESPACE_KEY
|
||||
import com.tangem.data.walletconnect.respond.WcRespondService
|
||||
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
|
||||
import com.tangem.data.walletconnect.utils.WcNetworksConverter
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.walletconnect.model.HandleMethodError
|
||||
import com.tangem.domain.walletconnect.model.WcEthMethod
|
||||
import com.tangem.domain.walletconnect.model.WcRequestError
|
||||
import com.tangem.domain.walletconnect.model.WcSession
|
||||
|
|
@ -13,9 +25,13 @@ import com.tangem.domain.walletconnect.usecase.method.WcNetworkDerivationState
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
internal class WcEthAddNetworkUseCase @AssistedInject constructor(
|
||||
private val respondService: WcRespondService,
|
||||
private val networksConverter: WcNetworksConverter,
|
||||
addSwitchCommonDelegateFactory: WcEthAddSwitchCommonDelegate.Factory,
|
||||
@Assisted val context: WcMethodUseCaseContext,
|
||||
@Assisted override val method: WcEthMethod.AddEthereumChain,
|
||||
) : WcAddNetworkUseCase {
|
||||
|
|
@ -31,10 +47,63 @@ internal class WcEthAddNetworkUseCase @AssistedInject constructor(
|
|||
else -> WcNetworkDerivationState.Single
|
||||
}
|
||||
|
||||
private val addSwitchCommonDelegate = addSwitchCommonDelegateFactory.create(context)
|
||||
|
||||
override suspend fun invoke(): Either<HandleMethodError, WcAddNetworkUseCase.AddNetwork> {
|
||||
return addSwitchCommonDelegate
|
||||
.commonChecks(method.rawChain.chainId)
|
||||
.map { addedNetwork ->
|
||||
WcAddNetworkUseCase.AddNetwork(
|
||||
network = addedNetwork,
|
||||
isExistInWcSession = addSwitchCommonDelegate.existInWcSession(addedNetwork),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun approve(): Either<WcRequestError, String> {
|
||||
fun illegalState() = WcRequestError.UnknownError(IllegalStateException("IllegalStateException")).left()
|
||||
val requestedNetworkCAIP2 = CAIP2.fromRaw(rawSdkRequest.chainId.orEmpty()) ?: return illegalState()
|
||||
val networkToAddCAIP2 = addSwitchCommonDelegate.hexChainIdToCAIP2(method.rawChain.chainId)
|
||||
?: return illegalState()
|
||||
val namespaces = session.sdkModel.namespaces[requestedNetworkCAIP2.namespace]
|
||||
?: return illegalState()
|
||||
// find and add all derivation
|
||||
val networkToAddCAIP10 = networksConverter
|
||||
.allAddressForChain(networkToAddCAIP2.raw, wallet)
|
||||
.map { address -> CAIP10(networkToAddCAIP2, address).raw }
|
||||
val newNamespaces = namespaces.copy(
|
||||
chains = namespaces.chains.plus(networkToAddCAIP2.raw),
|
||||
accounts = namespaces.accounts.plus(networkToAddCAIP10),
|
||||
)
|
||||
val sdkNewNamespaces = session.sdkModel.namespaces
|
||||
.plus(requestedNetworkCAIP2.namespace to newNamespaces)
|
||||
.mapValues { (_, session) ->
|
||||
Model.Namespace.Session(
|
||||
chains = session.chains,
|
||||
accounts = session.accounts,
|
||||
methods = session.methods,
|
||||
events = session.events,
|
||||
)
|
||||
}
|
||||
|
||||
val sessionUpdate = Wallet.Params.SessionUpdate(
|
||||
sessionTopic = context.session.sdkModel.topic,
|
||||
namespaces = sdkNewNamespaces,
|
||||
)
|
||||
sdkUpdateSession(sessionUpdate) // ignore result for now
|
||||
return respondService.respond(rawSdkRequest, "")
|
||||
}
|
||||
|
||||
private suspend fun sdkUpdateSession(sessionUpdate: Wallet.Params.SessionUpdate): Either<Throwable, Unit> {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
WalletKit.updateSession(
|
||||
params = sessionUpdate,
|
||||
onSuccess = { if (continuation.isActive) continuation.resume(Unit.right()) },
|
||||
onError = { if (continuation.isActive) continuation.resume(it.throwable.left()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun reject() {
|
||||
respondService.rejectRequestNonBlock(rawSdkRequest)
|
||||
}
|
||||
|
|
@ -43,4 +112,37 @@ internal class WcEthAddNetworkUseCase @AssistedInject constructor(
|
|||
interface Factory {
|
||||
fun create(context: WcMethodUseCaseContext, method: WcEthMethod.AddEthereumChain): WcEthAddNetworkUseCase
|
||||
}
|
||||
}
|
||||
|
||||
internal class WcEthAddSwitchCommonDelegate @AssistedInject constructor(
|
||||
private val networksConverter: WcNetworksConverter,
|
||||
@Assisted val context: WcMethodUseCaseContext,
|
||||
) {
|
||||
|
||||
private val wallet: UserWallet get() = context.session.wallet
|
||||
|
||||
fun hexChainIdToCAIP2(hexChainId: String): CAIP2? = CAIP2.fromRaw("$ETH_NAMESPACE_KEY:${hexChainId.hexToInt()}")
|
||||
|
||||
fun existInWcSession(network: Network): Boolean {
|
||||
return context.session.networks.any { it.rawId == network.rawId }
|
||||
}
|
||||
|
||||
suspend fun commonChecks(hexChainId: String): Either<HandleMethodError, Network> {
|
||||
val caip2 = hexChainIdToCAIP2(hexChainId)
|
||||
?: return HandleMethodError.UnknownError("Failed to parse CAIP2").left()
|
||||
val generalNetwork = networksConverter.createNetwork(caip2.raw, wallet)
|
||||
if (generalNetwork == null) {
|
||||
return HandleMethodError.TangemUnsupportedNetwork(caip2.raw).left()
|
||||
}
|
||||
val addedNetwork = networksConverter.mainOrAnyWalletNetworkForRequest(caip2.raw, wallet)
|
||||
if (addedNetwork == null) {
|
||||
return HandleMethodError.NotAddedNetwork(generalNetwork.name).left()
|
||||
}
|
||||
return addedNetwork.right()
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(context: WcMethodUseCaseContext): WcEthAddSwitchCommonDelegate
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,6 @@ import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter.Compani
|
|||
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
|
||||
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
|
||||
import com.tangem.data.walletconnect.utils.WcNetworksConverter
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.walletconnect.model.*
|
||||
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
|
|
@ -44,7 +43,7 @@ internal class WcEthNetwork(
|
|||
?: return HandleMethodError.UnknownSession.left()
|
||||
val wallet = session.wallet
|
||||
val chainId = request.chainId.orEmpty()
|
||||
val method: WcEthMethod = name.toMethod(request, wallet)
|
||||
val method: WcEthMethod = name.toMethod(request)
|
||||
.getOrElse { return error(it.message.orEmpty()) }
|
||||
?: return error("Failed to parse $name")
|
||||
suspend fun anyExistNetwork() = networksConverter.mainOrAnyWalletNetworkForRequest(chainId, wallet)
|
||||
|
|
@ -54,7 +53,9 @@ internal class WcEthNetwork(
|
|||
is WcEthMethod.SendTransaction -> method.transaction.from
|
||||
is WcEthMethod.SignTransaction -> method.transaction.from
|
||||
is WcEthMethod.SignTypedData -> method.account
|
||||
is WcEthMethod.AddEthereumChain ->
|
||||
is WcEthMethod.AddEthereumChain,
|
||||
is WcEthMethod.SwitchEthereumChain,
|
||||
->
|
||||
anyExistNetwork()
|
||||
?.let { network -> walletManagersFacade.getDefaultAddress(wallet.walletId, network).orEmpty() }
|
||||
.orEmpty()
|
||||
|
|
@ -65,7 +66,9 @@ internal class WcEthNetwork(
|
|||
is WcEthMethod.SendTransaction,
|
||||
is WcEthMethod.SignTransaction,
|
||||
-> networksConverter.findWalletNetworkForRequest(request, session, accountAddress)
|
||||
is WcEthMethod.AddEthereumChain -> anyExistNetwork()
|
||||
is WcEthMethod.AddEthereumChain,
|
||||
is WcEthMethod.SwitchEthereumChain,
|
||||
-> anyExistNetwork()
|
||||
} ?: return error("Failed to find walletNetwork for accountAddress $accountAddress")
|
||||
|
||||
val context = WcMethodUseCaseContext(
|
||||
|
|
@ -81,13 +84,11 @@ internal class WcEthNetwork(
|
|||
is WcEthMethod.SignTransaction -> factories.signTransaction.create(context, method)
|
||||
is WcEthMethod.SignTypedData -> factories.signTypedData.create(context, method)
|
||||
is WcEthMethod.AddEthereumChain -> factories.addNetwork.create(context, method)
|
||||
is WcEthMethod.SwitchEthereumChain -> factories.switchNetwork.create(context, method)
|
||||
}.right()
|
||||
}
|
||||
|
||||
private suspend fun WcEthMethodName.toMethod(
|
||||
request: WcSdkSessionRequest,
|
||||
wallet: UserWallet,
|
||||
): Either<Throwable, WcEthMethod?> {
|
||||
private fun WcEthMethodName.toMethod(request: WcSdkSessionRequest): Either<Throwable, WcEthMethod?> {
|
||||
val rawParams = request.request.params
|
||||
return when (this) {
|
||||
WcEthMethodName.EthSign,
|
||||
|
|
@ -109,14 +110,17 @@ internal class WcEthNetwork(
|
|||
}
|
||||
}
|
||||
?: return null.right()
|
||||
WcEthMethodName.AddEthereumChain -> moshi.fromJson<List<WcEthAddChain>>(rawParams)
|
||||
WcEthMethodName.AddEthereumChain,
|
||||
WcEthMethodName.SwitchEthereumChain,
|
||||
-> moshi.fromJson<List<WcEthAddChain>>(rawParams)
|
||||
.getOrElse { return it.left() }
|
||||
?.firstOrNull()
|
||||
?.let {
|
||||
val newNetwork = networksConverter
|
||||
.mainOrAnyWalletNetworkForRequest(it.chainId, wallet)
|
||||
?: return null.right()
|
||||
WcEthMethod.AddEthereumChain(rawChain = it, network = newNetwork).right()
|
||||
if (this == WcEthMethodName.AddEthereumChain) {
|
||||
WcEthMethod.AddEthereumChain(rawChain = it).right()
|
||||
} else {
|
||||
WcEthMethod.SwitchEthereumChain(rawChain = it).right()
|
||||
}
|
||||
}
|
||||
?: null.right()
|
||||
}
|
||||
|
|
@ -147,13 +151,17 @@ internal class WcEthNetwork(
|
|||
override val excludedBlockchains: ExcludedBlockchains,
|
||||
) : WcNamespaceConverter {
|
||||
|
||||
override val namespaceKey: NamespaceKey = NamespaceKey("eip155")
|
||||
override val namespaceKey: NamespaceKey = NamespaceKey(ETH_NAMESPACE_KEY)
|
||||
|
||||
override fun toBlockchain(chainId: CAIP2): Blockchain? {
|
||||
if (chainId.namespace != namespaceKey.key) return null
|
||||
val ethChainId = chainId.reference.toIntOrNull() ?: return null
|
||||
return Blockchain.fromChainId(ethChainId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ETH_NAMESPACE_KEY = "eip155"
|
||||
}
|
||||
}
|
||||
|
||||
internal class Factories @Inject constructor(
|
||||
|
|
@ -162,5 +170,6 @@ internal class WcEthNetwork(
|
|||
val sendTransaction: WcEthSendTransactionUseCase.Factory,
|
||||
val signTransaction: WcEthSignTransactionUseCase.Factory,
|
||||
val addNetwork: WcEthAddNetworkUseCase.Factory,
|
||||
val switchNetwork: WcEthSwitchNetworkUseCase.Factory,
|
||||
)
|
||||
}
|
||||
|
|
@ -7,6 +7,9 @@ import com.tangem.blockchain.common.TransactionData
|
|||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.extensions.formatHex
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.TxSentFrom
|
||||
import com.tangem.core.analytics.models.Basic
|
||||
import com.tangem.core.analytics.models.Basic.TransactionSent.MemoType
|
||||
import com.tangem.data.walletconnect.respond.WcRespondService
|
||||
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
|
||||
import com.tangem.data.walletconnect.sign.SignCollector
|
||||
|
|
@ -86,6 +89,16 @@ internal class WcEthSendTransactionUseCase @AssistedInject constructor(
|
|||
emit(state.toResult(parseSendError(error).left()))
|
||||
}
|
||||
.getOrNull() ?: return
|
||||
analytics.send(
|
||||
Basic.TransactionSent(
|
||||
sentFrom = TxSentFrom.WalletConnect(
|
||||
blockchain = network.name,
|
||||
token = network.currencySymbol,
|
||||
feeType = null,
|
||||
),
|
||||
memoType = MemoType.Null,
|
||||
),
|
||||
)
|
||||
val respondResult = respondService.respond(rawSdkRequest, hash.formatHex())
|
||||
emit(state.toResult(respondResult))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import com.tangem.blockchain.common.Amount as BlockchainAmount
|
|||
internal class WcEthSignTransactionUseCase @AssistedInject constructor(
|
||||
override val respondService: WcRespondService,
|
||||
override val analytics: AnalyticsEventHandler,
|
||||
private val prepareForSend: PrepareForSendUseCase,
|
||||
private val prepareForSend: PrepareForSendUseCase, // TODO: TODO("[REDACTED_JIRA]")
|
||||
private val ethTxHelper: WcEthTxHelper,
|
||||
@Assisted override val context: WcMethodUseCaseContext,
|
||||
@Assisted override val method: WcEthMethod.SignTransaction,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
package com.tangem.data.walletconnect.network.ethereum
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.data.walletconnect.respond.WcRespondService
|
||||
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.walletconnect.model.HandleMethodError
|
||||
import com.tangem.domain.walletconnect.model.WcEthMethod
|
||||
import com.tangem.domain.walletconnect.model.WcSession
|
||||
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
|
||||
import com.tangem.domain.walletconnect.usecase.method.WcNetworkDerivationState
|
||||
import com.tangem.domain.walletconnect.usecase.method.WcSwitchNetworkUseCase
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class WcEthSwitchNetworkUseCase @AssistedInject constructor(
|
||||
private val respondService: WcRespondService,
|
||||
@Assisted val context: WcMethodUseCaseContext,
|
||||
@Assisted override val method: WcEthMethod.SwitchEthereumChain,
|
||||
addSwitchCommonDelegateFactory: WcEthAddSwitchCommonDelegate.Factory,
|
||||
) : WcSwitchNetworkUseCase {
|
||||
|
||||
override val session: WcSession
|
||||
get() = context.session
|
||||
override val rawSdkRequest: WcSdkSessionRequest
|
||||
get() = context.rawSdkRequest
|
||||
override val network: Network
|
||||
get() = context.network
|
||||
override val derivationState: WcNetworkDerivationState = when {
|
||||
context.networkDerivationsCount > 1 -> WcNetworkDerivationState.Multiple(walletAddress = context.accountAddress)
|
||||
else -> WcNetworkDerivationState.Single
|
||||
}
|
||||
|
||||
private val addSwitchCommonDelegate = addSwitchCommonDelegateFactory.create(context)
|
||||
|
||||
override suspend fun invoke(): Either<HandleMethodError, WcSwitchNetworkUseCase.SwitchNetwork> {
|
||||
return addSwitchCommonDelegate
|
||||
.commonChecks(method.rawChain.chainId)
|
||||
.map { addedNetwork ->
|
||||
WcSwitchNetworkUseCase.SwitchNetwork(
|
||||
network = addedNetwork,
|
||||
isExistInWcSession = addSwitchCommonDelegate.existInWcSession(addedNetwork),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun reject() {
|
||||
respondService.rejectRequestNonBlock(rawSdkRequest)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(context: WcMethodUseCaseContext, method: WcEthMethod.SwitchEthereumChain): WcEthSwitchNetworkUseCase
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ package com.tangem.data.walletconnect.network.ethereum
|
|||
|
||||
import com.domain.blockaid.models.transaction.CheckTransactionResult
|
||||
import com.domain.blockaid.models.transaction.SimulationResult
|
||||
import com.domain.blockaid.models.transaction.simultation.ApprovedAmount
|
||||
import com.domain.blockaid.models.transaction.simultation.ApproveInfo
|
||||
import com.domain.blockaid.models.transaction.simultation.SimulationData
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
|
||||
import com.tangem.blockchain.blockchains.ethereum.tokenmethods.ApprovalERC20TokenCallData
|
||||
|
|
@ -68,13 +68,15 @@ internal class WcEthTxHelper @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
fun getApprovedAmount(txData: String?, result: CheckTransactionResult): ApprovedAmount? {
|
||||
fun getApprovedAmount(txData: String?, result: CheckTransactionResult): ApproveInfo.Amount? {
|
||||
val approvalMethodId = ApprovalERC20TokenCallData("", null).methodId
|
||||
val isApprovalWcMethod = txData?.startsWith(approvalMethodId)
|
||||
if (isApprovalWcMethod != true) return null
|
||||
val simulation = result.simulation as? SimulationResult.Success
|
||||
?: return null
|
||||
val approves = (simulation.data as? SimulationData.Approve)?.approvedAmounts
|
||||
val approves = (simulation.data as? SimulationData.Approve)
|
||||
?.items
|
||||
?.filterIsInstance<ApproveInfo.Amount>()
|
||||
?: return null
|
||||
if (approves.isEmpty()) return null
|
||||
val amount = approves.first()
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import com.tangem.data.walletconnect.sign.SignCollector
|
|||
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
|
||||
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
|
||||
import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate
|
||||
import com.tangem.domain.transaction.usecase.PrepareForSendUseCase
|
||||
import com.tangem.domain.transaction.usecase.PrepareAndSignUseCase
|
||||
import com.tangem.domain.walletconnect.error.parseSendError
|
||||
import com.tangem.domain.walletconnect.model.WcSolanaMethod
|
||||
import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck
|
||||
|
|
@ -29,7 +29,7 @@ import org.json.JSONObject
|
|||
internal class WcSolanaSignAllTransactionUseCase @AssistedInject constructor(
|
||||
override val respondService: WcRespondService,
|
||||
override val analytics: AnalyticsEventHandler,
|
||||
private val prepareForSend: PrepareForSendUseCase,
|
||||
private val prepareAndSign: PrepareAndSignUseCase,
|
||||
@Assisted override val context: WcMethodUseCaseContext,
|
||||
@Assisted override val method: WcSolanaMethod.SignAllTransaction,
|
||||
blockAidDelegate: BlockAidVerificationDelegate,
|
||||
|
|
@ -46,7 +46,7 @@ internal class WcSolanaSignAllTransactionUseCase @AssistedInject constructor(
|
|||
).map { lce -> lce.map { result -> BlockAidTransactionCheck.Result.Plain(result) } }
|
||||
|
||||
override suspend fun SignCollector<List<TransactionData>>.onSign(state: WcSignState<List<TransactionData>>) {
|
||||
val hash = prepareForSend.invoke(transactionData = state.signModel, userWallet = wallet, network = network)
|
||||
val hash = prepareAndSign.invoke(transactionData = state.signModel, userWallet = wallet, network = network)
|
||||
.onLeft { error ->
|
||||
emit(state.toResult(parseSendError(error).left()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import com.tangem.data.walletconnect.sign.SignCollector
|
|||
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
|
||||
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
|
||||
import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate
|
||||
import com.tangem.domain.transaction.usecase.PrepareForSendUseCase
|
||||
import com.tangem.domain.transaction.usecase.PrepareAndSignUseCase
|
||||
import com.tangem.domain.walletconnect.error.parseSendError
|
||||
import com.tangem.domain.walletconnect.model.WcSolanaMethod
|
||||
import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck
|
||||
|
|
@ -27,7 +27,7 @@ import okio.ByteString.Companion.decodeBase64
|
|||
internal class WcSolanaSignTransactionUseCase @AssistedInject constructor(
|
||||
override val respondService: WcRespondService,
|
||||
override val analytics: AnalyticsEventHandler,
|
||||
private val prepareForSend: PrepareForSendUseCase,
|
||||
private val prepareAndSign: PrepareAndSignUseCase,
|
||||
@Assisted override val context: WcMethodUseCaseContext,
|
||||
@Assisted override val method: WcSolanaMethod.SignTransaction,
|
||||
blockAidDelegate: BlockAidVerificationDelegate,
|
||||
|
|
@ -44,7 +44,7 @@ internal class WcSolanaSignTransactionUseCase @AssistedInject constructor(
|
|||
).map { lce -> lce.map { result -> BlockAidTransactionCheck.Result.Plain(result) } }
|
||||
|
||||
override suspend fun SignCollector<TransactionData>.onSign(state: WcSignState<TransactionData>) {
|
||||
val hash = prepareForSend.invoke(transactionData = state.signModel, userWallet = wallet, network = network)
|
||||
val hash = prepareAndSign.invoke(transactionData = state.signModel, userWallet = wallet, network = network)
|
||||
.onLeft { error ->
|
||||
emit(state.toResult(parseSendError(error).left()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,12 @@ import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.joda.time.DateTime
|
||||
import timber.log.Timber
|
||||
import java.net.URI
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultWcPairUseCase @AssistedInject constructor(
|
||||
|
|
@ -63,6 +65,12 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
|
|||
return@flow
|
||||
}
|
||||
|
||||
val dAppUri = URI(sdkSessionProposal.url)
|
||||
if (dAppUri.host.isNullOrEmpty()) {
|
||||
emit(WcPairState.Error(WcPairError.InvalidDomainURL))
|
||||
return@flow
|
||||
}
|
||||
|
||||
val proposalState = buildProposalState(sdkSessionProposal, sdkVerifyContext)
|
||||
.onLeft {
|
||||
analytics.send(WcAnalyticEvents.PairFailed(it.code))
|
||||
|
|
@ -112,14 +120,21 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
|
|||
Timber.tag(WC_TAG).e(it, "Failed to approve session ${sdkSessionProposal.name}")
|
||||
}
|
||||
emit(WcPairState.Approving.Result(sessionForApprove, either))
|
||||
}.onCompletion {
|
||||
if (it != null) {
|
||||
Timber.tag(WC_TAG).e(it, "Completed with error $pairRequest")
|
||||
emit(WcPairState.Error(WcPairError.Unknown(it.message.orEmpty())))
|
||||
} else {
|
||||
Timber.tag(WC_TAG).i("Completed successfully $pairRequest")
|
||||
}
|
||||
}
|
||||
.catch {
|
||||
val pairError: WcPairError = when (it) {
|
||||
is TimeoutCancellationException -> WcPairError.TimeoutException(it.message.orEmpty())
|
||||
else -> WcPairError.Unknown(it.message.orEmpty())
|
||||
}
|
||||
emit(WcPairState.Error(pairError))
|
||||
}
|
||||
.onCompletion {
|
||||
if (it != null) {
|
||||
Timber.tag(WC_TAG).e(it, "Completed with error $pairRequest")
|
||||
} else {
|
||||
Timber.tag(WC_TAG).i("Completed successfully $pairRequest")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun approve(sessionForApprove: WcSessionApprove) {
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ internal class WcPairSdkDelegate : WcSdkObserver {
|
|||
private fun Throwable.toApproveError() = WcPairError.ApprovalFailed(this.localizedMessage.orEmpty()).left()
|
||||
|
||||
companion object {
|
||||
private const val CALLBACK_TIMEOUT = 60
|
||||
private const val CALLBACK_TIMEOUT = 15
|
||||
// com.reown.android.pairing.engine.domain.PairingEngine.pair
|
||||
private val pairingExpiredMessages = listOf(
|
||||
"Pairing URI expired",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ internal class DefaultWcRequestService(
|
|||
Timber.tag(WC_TAG).i("handle request name $name")
|
||||
if (name is WcMethodName.Unsupported) {
|
||||
respondService.rejectRequestNonBlock(sr)
|
||||
if (name.raw.startsWith("wallet_")) return
|
||||
}
|
||||
_wcRequest.trySend(name to sr)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,31 +4,32 @@ import com.tangem.blockchain.common.Blockchain
|
|||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.utils.converter.Converter
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class BlockAidChainNameConverter @Inject constructor() : Converter<Network, String> {
|
||||
internal object BlockAidChainNameConverter : Converter<Network, String?> {
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
override fun convert(value: Network): String {
|
||||
override fun convert(value: Network): String? {
|
||||
return when (Blockchain.fromNetworkId(value.backendId)) {
|
||||
Blockchain.Arbitrum -> "arbitrum"
|
||||
Blockchain.Avalanche -> "avalanche"
|
||||
Blockchain.AvalancheTestnet -> "avalanche-fuji"
|
||||
Blockchain.Binance, Blockchain.BSC -> "bsc"
|
||||
Blockchain.Ethereum -> "ethereum"
|
||||
Blockchain.EthereumTestnet -> "ethereum-sepolia"
|
||||
Blockchain.Polygon -> "polygon"
|
||||
Blockchain.Solana -> "mainnet"
|
||||
Blockchain.Gnosis -> "gnosis"
|
||||
Blockchain.Optimism -> "optimism"
|
||||
Blockchain.ZkSyncEra -> "zksync"
|
||||
Blockchain.ZkSyncEraTestnet -> "zksync-sepolia"
|
||||
Blockchain.Base -> "base"
|
||||
Blockchain.BaseTestnet -> "base-sepolia"
|
||||
Blockchain.Binance, Blockchain.BSC -> "bsc"
|
||||
Blockchain.Ethereum -> "ethereum"
|
||||
Blockchain.Optimism -> "optimism"
|
||||
Blockchain.Polygon -> "polygon"
|
||||
Blockchain.ZkSyncEra -> "zksync"
|
||||
Blockchain.ZkSyncEraTestnet -> "zksync-sepolia"
|
||||
Blockchain.Blast, Blockchain.BlastTestnet -> "blast"
|
||||
Blockchain.ApeChain, Blockchain.ApeChainTestnet -> "apechain"
|
||||
Blockchain.Scroll -> "scroll"
|
||||
else -> value.name
|
||||
Blockchain.EthereumTestnet -> "ethereum-sepolia"
|
||||
Blockchain.Gnosis -> "gnosis"
|
||||
Blockchain.ApeChain, Blockchain.ApeChainTestnet -> "apechain"
|
||||
|
||||
Blockchain.Solana -> "mainnet"
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,6 @@ import javax.inject.Inject
|
|||
|
||||
internal class BlockAidVerificationDelegate @Inject constructor(
|
||||
private val blockAidVerifier: BlockAidVerifier,
|
||||
private val blockAidChainNameConverter: BlockAidChainNameConverter,
|
||||
) {
|
||||
|
||||
fun getSecurityStatus(
|
||||
|
|
@ -27,7 +26,6 @@ internal class BlockAidVerificationDelegate @Inject constructor(
|
|||
session: WcSession,
|
||||
accountAddress: String?,
|
||||
): LceFlow<Throwable, CheckTransactionResult> = flow {
|
||||
emit(Lce.Loading(partialContent = null))
|
||||
val failedResult = CheckTransactionResult(
|
||||
validation = ValidationResult.FAILED_TO_VALIDATE,
|
||||
simulation = SimulationResult.FailedToSimulate,
|
||||
|
|
@ -36,7 +34,21 @@ internal class BlockAidVerificationDelegate @Inject constructor(
|
|||
emit(Lce.Content(failedResult))
|
||||
return@flow
|
||||
}
|
||||
when (method) {
|
||||
val chain = BlockAidChainNameConverter.convert(network)
|
||||
if (chain == null) {
|
||||
emit(Lce.Content(failedResult))
|
||||
return@flow
|
||||
}
|
||||
emit(Lce.Loading(partialContent = null))
|
||||
val methodName = when (method) {
|
||||
is WcEthMethod -> rawSdkRequest.request.method
|
||||
is WcSolanaMethod -> method.trimmedPrefixMethodName
|
||||
is WcMethod.Unsupported -> {
|
||||
emit(Lce.Content(failedResult))
|
||||
return@flow
|
||||
}
|
||||
}
|
||||
val params = when (method) {
|
||||
is WcEthMethod -> TransactionParams.Evm(rawSdkRequest.request.params)
|
||||
is WcSolanaMethod.SignAllTransaction -> TransactionParams.Solana(method.transaction)
|
||||
is WcSolanaMethod.SignTransaction -> TransactionParams.Solana(listOf(method.transaction))
|
||||
|
|
@ -45,25 +57,28 @@ internal class BlockAidVerificationDelegate @Inject constructor(
|
|||
emit(Lce.Content(failedResult))
|
||||
return@flow
|
||||
}
|
||||
else -> null
|
||||
}?.let { params ->
|
||||
blockAidVerifier.verifyTransaction(
|
||||
TransactionData(
|
||||
chain = blockAidChainNameConverter.convert(network),
|
||||
accountAddress = accountAddress,
|
||||
method = rawSdkRequest.request.method,
|
||||
domainUrl = session.sdkModel.appMetaData.url,
|
||||
params = params,
|
||||
),
|
||||
).fold(
|
||||
ifLeft = {
|
||||
Timber.e("Failed to verify transaction: ${it.localizedMessage}")
|
||||
emit(Lce.Error(it))
|
||||
},
|
||||
ifRight = {
|
||||
emit(Lce.Content(it))
|
||||
},
|
||||
)
|
||||
} ?: emit(Lce.Content(failedResult))
|
||||
else -> {
|
||||
emit(Lce.Content(failedResult))
|
||||
return@flow
|
||||
}
|
||||
}
|
||||
|
||||
blockAidVerifier.verifyTransaction(
|
||||
data = TransactionData(
|
||||
chain = chain,
|
||||
accountAddress = accountAddress,
|
||||
method = methodName,
|
||||
domainUrl = session.sdkModel.appMetaData.url,
|
||||
params = params,
|
||||
),
|
||||
).fold(
|
||||
ifLeft = {
|
||||
Timber.e("Failed to verify transaction: ${it.localizedMessage}")
|
||||
emit(Lce.Error(it))
|
||||
},
|
||||
ifRight = {
|
||||
emit(Lce.Content(it))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,11 @@ internal class WcNetworksConverter @Inject constructor(
|
|||
private val tokensFeatureToggles: TokensFeatureToggles,
|
||||
) {
|
||||
|
||||
fun createNetwork(chainId: String, wallet: UserWallet): Network? {
|
||||
return namespaceConverters
|
||||
.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) }
|
||||
}
|
||||
|
||||
suspend fun findWalletNetworkForRequest(
|
||||
request: WcSdkSessionRequest,
|
||||
session: WcSession,
|
||||
|
|
@ -48,6 +53,11 @@ internal class WcNetworksConverter @Inject constructor(
|
|||
return networks.firstOrNull { !isCustomCoin(it) } ?: networks.firstOrNull()
|
||||
}
|
||||
|
||||
suspend fun allAddressForChain(rawChainId: String, wallet: UserWallet): List<String> {
|
||||
return filterWalletNetworkForRequest(rawChainId, wallet)
|
||||
.mapNotNull { walletManagersFacade.getDefaultAddress(wallet.walletId, it)?.lowercase() }
|
||||
}
|
||||
|
||||
/**
|
||||
* return all exist derivation networks
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@ internal object WcSdkSessionConverter : Converter<Wallet.Model.Session, WcSdkSes
|
|||
return WcSdkSession(
|
||||
topic = value.topic,
|
||||
appMetaData = value.metaData?.let { WcAppMetaDataConverter.convert(it) } ?: WcAppMetaDataConverter.empty,
|
||||
namespaces = value.namespaces.mapValues { (_, session) ->
|
||||
WcSdkSession.Session(
|
||||
chains = session.chains ?: listOf(),
|
||||
accounts = session.accounts,
|
||||
methods = session.methods,
|
||||
events = session.events,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ internal class DefaultWcPairUseCaseTest {
|
|||
pairingTopic = "",
|
||||
name = "",
|
||||
description = "",
|
||||
url = "",
|
||||
url = "https://react-app.walletconnect.com/",
|
||||
icons = listOf(),
|
||||
redirect = "",
|
||||
requiredNamespaces = mapOf(),
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ internal class WcSignUseCaseDelegateTest {
|
|||
connectingTime = 0L,
|
||||
sdkModel = WcSdkSession(
|
||||
topic = "",
|
||||
namespaces = mapOf(),
|
||||
appMetaData = WcAppMetaData(
|
||||
name = "",
|
||||
description = "",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import com.tangem.data.walletmanager.extensions.makeWalletManagerForApp
|
|||
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.config.curvesConfig
|
||||
import com.tangem.domain.wallets.derivations.derivationStyleProvider
|
||||
import timber.log.Timber
|
||||
|
||||
|
|
@ -41,7 +42,7 @@ internal class WalletManagerFactory(
|
|||
blockchain: Blockchain,
|
||||
derivationPath: DerivationPath?,
|
||||
): WalletManager? {
|
||||
val curve = blockchain.getSupportedCurves().first()
|
||||
val curve = hotWallet.curvesConfig.primaryCurve(blockchain)
|
||||
val selectedWallet = hotWallet.wallets.orEmpty().firstOrNull { it.curve == curve }
|
||||
?: return null
|
||||
return try {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.SEED_FIRST_NOTIFI
|
|||
import com.tangem.datasource.local.preferences.utils.get
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectMap
|
||||
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
|
||||
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
|
||||
import com.tangem.datasource.local.preferences.utils.store
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
|
|
@ -47,14 +48,78 @@ internal class DefaultWalletsRepository(
|
|||
return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false)
|
||||
}
|
||||
|
||||
@Deprecated("Hot wallet feature makes app always save user wallets. Do not use this method")
|
||||
override fun shouldSaveUserWallets(): Flow<Boolean> {
|
||||
return appPreferencesStore.get(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false)
|
||||
}
|
||||
|
||||
@Deprecated("Hot wallet feature makes app always save user wallets. Do not use this method")
|
||||
override suspend fun saveShouldSaveUserWallets(item: Boolean) {
|
||||
appPreferencesStore.store(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, value = item)
|
||||
}
|
||||
|
||||
override suspend fun useBiometricAuthentication(): Boolean {
|
||||
val useBiometricAuthentication = appPreferencesStore.getSyncOrNull(
|
||||
key = PreferencesKeys.USE_BIOMETRIC_AUTHENTICATION_KEY,
|
||||
)
|
||||
|
||||
if (useBiometricAuthentication != null) {
|
||||
return useBiometricAuthentication
|
||||
}
|
||||
|
||||
val legacySaveWalletsInTheApp = appPreferencesStore.getSyncOrNull(
|
||||
key = PreferencesKeys.SAVE_USER_WALLETS_KEY,
|
||||
)
|
||||
|
||||
if (legacySaveWalletsInTheApp != null) {
|
||||
// Migrate legacy setting to new one
|
||||
appPreferencesStore.store(
|
||||
key = PreferencesKeys.USE_BIOMETRIC_AUTHENTICATION_KEY,
|
||||
value = legacySaveWalletsInTheApp,
|
||||
)
|
||||
return legacySaveWalletsInTheApp
|
||||
} else {
|
||||
// Default value for new users
|
||||
setUseBiometricAuthentication(false)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setUseBiometricAuthentication(value: Boolean) {
|
||||
appPreferencesStore.store(key = PreferencesKeys.USE_BIOMETRIC_AUTHENTICATION_KEY, value = value)
|
||||
}
|
||||
|
||||
override suspend fun requireAccessCode(): Boolean {
|
||||
val requireAccessCode = appPreferencesStore.getSyncOrNull(
|
||||
key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY,
|
||||
)
|
||||
|
||||
if (requireAccessCode != null) {
|
||||
return requireAccessCode
|
||||
}
|
||||
|
||||
val legacyShouldSaveAccessCode = appPreferencesStore.getSyncOrNull(
|
||||
key = PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY,
|
||||
)
|
||||
|
||||
if (legacyShouldSaveAccessCode != null) {
|
||||
// Migrate legacy setting to new one
|
||||
appPreferencesStore.store(
|
||||
key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY,
|
||||
value = legacyShouldSaveAccessCode.not(),
|
||||
)
|
||||
return legacyShouldSaveAccessCode.not()
|
||||
} else {
|
||||
// Default value for new users
|
||||
setRequireAccessCode(true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setRequireAccessCode(value: Boolean) {
|
||||
appPreferencesStore.store(key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY, value = value)
|
||||
}
|
||||
|
||||
override suspend fun isWalletWithRing(userWalletId: UserWalletId): Boolean {
|
||||
return appPreferencesStore
|
||||
.getSyncOrDefault(key = PreferencesKeys.ADDED_WALLETS_WITH_RING_KEY, default = emptySet())
|
||||
|
|
|
|||
|
|
@ -7,12 +7,11 @@ import com.tangem.common.card.EllipticCurve
|
|||
import com.tangem.common.extensions.ByteArrayKey
|
||||
import com.tangem.common.extensions.toMapKey
|
||||
import com.tangem.crypto.hdWallet.DerivationPath
|
||||
import com.tangem.domain.card.configs.CardConfig
|
||||
import com.tangem.domain.card.configs.Wallet2CardConfig
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.scan.KeyWalletPublicKey
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.config.curvesConfig
|
||||
import com.tangem.domain.wallets.derivations.derivationStyleProvider
|
||||
import com.tangem.operations.derivation.ExtendedPublicKeysMap
|
||||
import kotlin.collections.forEach
|
||||
|
|
@ -51,13 +50,9 @@ internal class MissedDerivationsFinder(private val userWallet: UserWallet) {
|
|||
}
|
||||
|
||||
private fun List<Network>.mapToNewDerivations(): List<DerivationData> {
|
||||
val config = when (userWallet) {
|
||||
is UserWallet.Cold -> CardConfig.createConfig(userWallet.scanResponse.card)
|
||||
is UserWallet.Hot -> Wallet2CardConfig // TODO [REDACTED_TASK_KEY] [Hot Wallet] Derivation config for hot wallet
|
||||
}
|
||||
return mapNotNull { network ->
|
||||
val blockchain = network.toBlockchain()
|
||||
val curve = config.primaryCurve(blockchain) ?: return@mapNotNull null
|
||||
val curve = userWallet.curvesConfig.primaryCurve(blockchain) ?: return@mapNotNull null
|
||||
|
||||
val walletPublicKey = when (userWallet) {
|
||||
is UserWallet.Cold -> {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import com.tangem.data.wallets.DefaultWalletsRepository
|
|||
import com.tangem.data.wallets.cold.DefaultColdMapDerivationsRepository
|
||||
import com.tangem.data.wallets.derivations.DefaultDerivationsRepository
|
||||
import com.tangem.data.wallets.hot.DefaultHotMapDerivationsRepository
|
||||
import com.tangem.data.wallets.hot.DefaultHotWalletAccessCodeAttemptsRepository
|
||||
import com.tangem.datasource.api.common.AuthProvider
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.datastore.RuntimeStateStore
|
||||
|
|
@ -13,6 +14,7 @@ import com.tangem.datasource.local.userwallet.UserWalletsStore
|
|||
import com.tangem.domain.wallets.derivations.ColdMapDerivationsRepository
|
||||
import com.tangem.domain.wallets.derivations.DerivationsRepository
|
||||
import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository
|
||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
|
||||
import com.tangem.domain.wallets.repository.WalletNamesMigrationRepository
|
||||
import com.tangem.domain.wallets.repository.WalletsRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
|
|
@ -68,4 +70,10 @@ internal interface WalletsDataBindsModule {
|
|||
@Binds
|
||||
@Singleton
|
||||
fun bindColdMapDerivationsRepository(impl: DefaultColdMapDerivationsRepository): ColdMapDerivationsRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindHotWalletAccessCodeAttemptsRepository(
|
||||
impl: DefaultHotWalletAccessCodeAttemptsRepository,
|
||||
): HotWalletAccessCodeAttemptsRepository
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
package com.tangem.data.wallets.hot
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
|
||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts
|
||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.ATTEMPTS_BEFORE_DELETION
|
||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.COOLDOWN_SECONDS
|
||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.MAX_ATTEMPTS_BEFORE_DELETION
|
||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.MAX_FAST_FORWARD_ATTEMPTS
|
||||
import com.tangem.hot.sdk.model.HotWalletId
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
class DefaultHotWalletAccessCodeAttemptsRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
) : HotWalletAccessCodeAttemptsRepository {
|
||||
|
||||
override suspend fun incrementAttempts(id: HotWalletAccessCodeAttemptsRepository.AttemptId) {
|
||||
val attemptsKey = PreferencesKeys.getHotWalletUnlockAttemptsKey(id.attemptIdKey())
|
||||
|
||||
appPreferencesStore.editData { preferences ->
|
||||
val currentAttempts = preferences[attemptsKey] ?: 0
|
||||
val newAttempts = currentAttempts + 1
|
||||
|
||||
preferences[attemptsKey] = newAttempts
|
||||
val currentBootCount = currentBootCount()
|
||||
preferences[PreferencesKeys.getHotWalletUnlockBootKey(id.attemptIdKey())] = currentBootCount
|
||||
|
||||
if (newAttempts >= MAX_FAST_FORWARD_ATTEMPTS) {
|
||||
val currentDeadline = SystemClock.elapsedRealtime() + COOLDOWN_SECONDS * 1000
|
||||
preferences[PreferencesKeys.getHotWalletUnlockDeadlineKey(id.attemptIdKey())] = currentDeadline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resetAttempts(hotWalletId: HotWalletId) {
|
||||
val authAttemptId = HotWalletAccessCodeAttemptsRepository.AttemptId(
|
||||
hotWalletId = hotWalletId,
|
||||
auth = true,
|
||||
)
|
||||
val noAuthAttemptId = HotWalletAccessCodeAttemptsRepository.AttemptId(
|
||||
hotWalletId = hotWalletId,
|
||||
auth = false,
|
||||
)
|
||||
|
||||
appPreferencesStore.editData {
|
||||
it.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(authAttemptId.attemptIdKey()))
|
||||
it.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(noAuthAttemptId.attemptIdKey()))
|
||||
it.remove(PreferencesKeys.getHotWalletUnlockBootKey(authAttemptId.attemptIdKey()))
|
||||
it.remove(PreferencesKeys.getHotWalletUnlockBootKey(noAuthAttemptId.attemptIdKey()))
|
||||
it.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(authAttemptId.attemptIdKey()))
|
||||
it.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(noAuthAttemptId.attemptIdKey()))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun getAttempts(id: HotWalletAccessCodeAttemptsRepository.AttemptId): Flow<Attempts> {
|
||||
val flow = appPreferencesStore.data.map {
|
||||
AttemptsPersistentData(
|
||||
attempts = it[PreferencesKeys.getHotWalletUnlockAttemptsKey(id.attemptIdKey())] ?: 0,
|
||||
bootCount = it[PreferencesKeys.getHotWalletUnlockBootKey(id.attemptIdKey())] ?: 0,
|
||||
deadline = it[PreferencesKeys.getHotWalletUnlockDeadlineKey(id.attemptIdKey())] ?: 0L,
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
return flow.transformLatest {
|
||||
while (true) {
|
||||
emit(toState(id, it.attempts, it.deadline, it.bootCount))
|
||||
val remaining = remainingSeconds(it.deadline, it.bootCount)
|
||||
if (remaining <= 0) break
|
||||
delay(timeMillis = 1000)
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
override suspend fun getAttemptsSync(id: HotWalletAccessCodeAttemptsRepository.AttemptId): Attempts {
|
||||
val prefs = appPreferencesStore.data.first()
|
||||
val count = prefs[PreferencesKeys.getHotWalletUnlockAttemptsKey(id.attemptIdKey())] ?: 0
|
||||
val boot = prefs[PreferencesKeys.getHotWalletUnlockBootKey(id.attemptIdKey())] ?: 0
|
||||
val deadline = prefs[PreferencesKeys.getHotWalletUnlockDeadlineKey(id.attemptIdKey())] ?: 0L
|
||||
return toState(id, count, deadline, boot)
|
||||
}
|
||||
|
||||
private fun remainingSeconds(deadline: Long, bootStored: Int): Int {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val bootNow = currentBootCount()
|
||||
if (bootNow != bootStored) {
|
||||
// If the boot happened after the last attempt, we consider timer to start from the beginning
|
||||
return maxOf(0, COOLDOWN_SECONDS - (now / 1000).toInt())
|
||||
}
|
||||
return maxOf(0, ((deadline - now) / 1000).toInt())
|
||||
}
|
||||
|
||||
private fun toState(
|
||||
id: HotWalletAccessCodeAttemptsRepository.AttemptId,
|
||||
count: Int,
|
||||
deadlineElapsed: Long,
|
||||
bootStored: Int,
|
||||
): Attempts {
|
||||
val fast = MAX_FAST_FORWARD_ATTEMPTS
|
||||
val attention = ATTEMPTS_BEFORE_DELETION
|
||||
val deletion = MAX_ATTEMPTS_BEFORE_DELETION
|
||||
|
||||
return when {
|
||||
count < fast -> Attempts.FastForward(count)
|
||||
id.auth && count >= deletion -> Attempts.Deletion
|
||||
id.auth && count >= attention -> {
|
||||
val remaining = remainingSeconds(deadlineElapsed, bootStored)
|
||||
Attempts.BeforeDeletion(count, remaining, deletion - count)
|
||||
}
|
||||
else -> {
|
||||
val remaining = remainingSeconds(deadlineElapsed, bootStored)
|
||||
Attempts.WithDelay(count, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun HotWalletAccessCodeAttemptsRepository.AttemptId.attemptIdKey(): String {
|
||||
return "${hotWalletId.value}_$auth"
|
||||
}
|
||||
|
||||
private fun currentBootCount(): Int = Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, 0)
|
||||
|
||||
private data class AttemptsPersistentData(
|
||||
val attempts: Int,
|
||||
val bootCount: Int,
|
||||
val deadline: Long,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
package com.tangem.data.wallets.hot
|
||||
|
||||
import com.tangem.common.core.TangemSdkError
|
||||
import com.tangem.domain.core.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.copy
|
||||
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
|
||||
import com.tangem.domain.wallets.repository.WalletsRepository
|
||||
import com.tangem.hot.sdk.TangemHotSdk
|
||||
import com.tangem.hot.sdk.exception.WrongPasswordException
|
||||
import com.tangem.hot.sdk.model.*
|
||||
|
|
@ -9,7 +13,9 @@ import javax.inject.Inject
|
|||
|
||||
class HotWalletAccessor @Inject constructor(
|
||||
private val tangemHotSdk: TangemHotSdk,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val hotWalletPasswordRequester: HotWalletPasswordRequester,
|
||||
private val walletsRepository: WalletsRepository,
|
||||
) {
|
||||
|
||||
suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> =
|
||||
|
|
@ -23,10 +29,24 @@ class HotWalletAccessor @Inject constructor(
|
|||
}
|
||||
|
||||
private suspend fun <T> hotSdkRequest(hotWalletId: HotWalletId, block: suspend (unlock: UnlockHotWallet) -> T): T {
|
||||
val isAccessCodeRequired = walletsRepository.requireAccessCode()
|
||||
|
||||
val auth = when (hotWalletId.authType) {
|
||||
HotWalletId.AuthType.NoPassword -> HotAuth.NoAuth
|
||||
HotWalletId.AuthType.Password -> requestPassword(false)
|
||||
HotWalletId.AuthType.Biometry -> HotAuth.Biometry
|
||||
HotWalletId.AuthType.Password -> requestPassword(
|
||||
hotWalletId = hotWalletId,
|
||||
hasBiometry = false,
|
||||
)
|
||||
HotWalletId.AuthType.Biometry -> {
|
||||
if (isAccessCodeRequired) {
|
||||
requestPassword(
|
||||
hotWalletId = hotWalletId,
|
||||
hasBiometry = false,
|
||||
)
|
||||
} else {
|
||||
HotAuth.Biometry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return runCatchingSdkErrors(hotWalletId, auth) {
|
||||
|
|
@ -42,27 +62,49 @@ class HotWalletAccessor @Inject constructor(
|
|||
block: suspend (auth: HotAuth) -> T,
|
||||
): T {
|
||||
return runCatchingWrongPassInternal(
|
||||
hotWalletId = hotWalletId,
|
||||
originalAuth = auth,
|
||||
auth = auth,
|
||||
block = { blockAuth ->
|
||||
block(blockAuth).also {
|
||||
// TODO [REDACTED_TASK_KEY] [Hot Wallet] Authorization by access code
|
||||
// if user has biometry enabled, we set it as the new auth method
|
||||
if (blockAuth is HotAuth.Password /*&& has biometry enabled */) {
|
||||
tangemHotSdk.changeAuth(
|
||||
unlockHotWallet = UnlockHotWallet(
|
||||
walletId = hotWalletId,
|
||||
auth = blockAuth,
|
||||
),
|
||||
auth = HotAuth.Biometry,
|
||||
)
|
||||
}
|
||||
// Update biometry auth if the original auth was password
|
||||
updateBiometryAuthIfNeeded(
|
||||
hotWalletId = hotWalletId,
|
||||
originalAuth = blockAuth,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun updateBiometryAuthIfNeeded(hotWalletId: HotWalletId, originalAuth: HotAuth) {
|
||||
val isAccessCodeRequired = walletsRepository.requireAccessCode()
|
||||
|
||||
if (originalAuth is HotAuth.Password && isAccessCodeRequired.not()) {
|
||||
val userWallet = userWalletsListRepository.userWalletsSync()
|
||||
.find { it is UserWallet.Hot && it.hotWalletId == hotWalletId }
|
||||
as? UserWallet.Hot
|
||||
?: return
|
||||
|
||||
val newHotWalletId = tangemHotSdk.changeAuth(
|
||||
unlockHotWallet = UnlockHotWallet(
|
||||
walletId = hotWalletId,
|
||||
auth = originalAuth,
|
||||
),
|
||||
auth = HotAuth.Biometry,
|
||||
)
|
||||
|
||||
userWalletsListRepository.saveWithoutLock(
|
||||
userWallet = userWallet.copy(
|
||||
hotWalletId = newHotWalletId,
|
||||
),
|
||||
canOverride = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> runCatchingWrongPassInternal(
|
||||
hotWalletId: HotWalletId,
|
||||
originalAuth: HotAuth,
|
||||
auth: HotAuth,
|
||||
block: suspend (auth: HotAuth) -> T,
|
||||
|
|
@ -71,9 +113,13 @@ class HotWalletAccessor @Inject constructor(
|
|||
}.getOrElse { exception ->
|
||||
if (auth is HotAuth.Biometry && exception.isBiometryError()) {
|
||||
// fallback to password if biometry fails
|
||||
val passAuth = requestPassword(true)
|
||||
val passAuth = requestPassword(
|
||||
hotWalletId = hotWalletId,
|
||||
hasBiometry = true,
|
||||
)
|
||||
|
||||
return@getOrElse runCatchingWrongPassInternal(
|
||||
hotWalletId = hotWalletId,
|
||||
originalAuth = originalAuth,
|
||||
auth = passAuth,
|
||||
block = block,
|
||||
|
|
@ -87,17 +133,28 @@ class HotWalletAccessor @Inject constructor(
|
|||
// If the exception is a wrong password, we need to request the password again
|
||||
|
||||
hotWalletPasswordRequester.wrongPassword()
|
||||
val passResult = requestPassword(originalAuth is HotAuth.Biometry)
|
||||
val passResult = requestPassword(
|
||||
hotWalletId = hotWalletId,
|
||||
hasBiometry = originalAuth is HotAuth.Biometry,
|
||||
)
|
||||
|
||||
runCatchingWrongPassInternal(
|
||||
hotWalletId = hotWalletId,
|
||||
originalAuth = originalAuth,
|
||||
auth = passResult,
|
||||
block = block,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun requestPassword(hasBiometry: Boolean): HotAuth {
|
||||
return hotWalletPasswordRequester.requestPassword(hasBiometry).toAuth() ?: throw TangemSdkError.UserCancelled()
|
||||
private suspend fun requestPassword(hotWalletId: HotWalletId, hasBiometry: Boolean): HotAuth {
|
||||
val attemptRequest = HotWalletPasswordRequester.AttemptRequest(
|
||||
hotWalletId = hotWalletId,
|
||||
authMode = false,
|
||||
hasBiometry = hasBiometry,
|
||||
)
|
||||
|
||||
return hotWalletPasswordRequester.requestPassword(attemptRequest).toAuth()
|
||||
?: throw TangemSdkError.UserCancelled()
|
||||
}
|
||||
|
||||
private fun Throwable.isBiometryError(): Boolean {
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ class DefaultWalletsRepositoryTest {
|
|||
)
|
||||
|
||||
val authProvider = mockk<AuthProvider> {
|
||||
every { getCardsPublicKeys() } returns publicKeys
|
||||
coEvery { getCardsPublicKeys() } returns publicKeys
|
||||
}
|
||||
|
||||
repository = DefaultWalletsRepository(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue