diff --git a/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt index 597915ff66..abc3d9f69b 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt @@ -59,6 +59,22 @@ internal class DefaultAuthProvider( } } + override fun getGaslessServiceApiKey(apiEnvironment: Provider): ProviderSuspend { + return ProviderSuspend { + when (apiEnvironment.invoke()) { + ApiEnvironment.DEV, + ApiEnvironment.DEV_2, + ApiEnvironment.DEV_3, + -> environmentConfigStorage.getConfigSync().tangemApiKeyDev // TODO add gaslessTxApiKeyDev + ApiEnvironment.STAGE, + ApiEnvironment.STAGE_2, + -> environmentConfigStorage.getConfigSync().tangemApiKeyStage // TODO add gaslessTxApiKeyStage + ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().tangemApiKey // TODO add gaslessTxApiKey + else -> error("No gasless tx api config provided for ${apiEnvironment.invoke()}") + } ?: error("No gasless tx api config provided") + } + } + private suspend fun getWallets(): List { return if (shouldUseNewListRepository) { userWalletsListRepository.userWalletsSync() diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/AuthProvider.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/AuthProvider.kt index 002f8656ed..75cf5c12bd 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/AuthProvider.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/AuthProvider.kt @@ -18,6 +18,8 @@ interface AuthProvider { fun getApiKey(apiEnvironment: Provider): ProviderSuspend + fun getGaslessServiceApiKey(apiEnvironment: Provider): ProviderSuspend + /** * Returns map where keys(cardId) associated with cardPublicKey */ diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index 8e0a661871..13b9af6b94 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -31,6 +31,7 @@ sealed class ApiConfig { YieldSupply, MoonPay, News, + GaslessTxService, } private fun initializeId(): ID { @@ -45,6 +46,7 @@ sealed class ApiConfig { is YieldSupply -> ID.YieldSupply is MoonPay -> ID.MoonPay is News -> ID.News + is GaslessTxService -> ID.GaslessTxService } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt new file mode 100644 index 0000000000..ce201f58bb --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt @@ -0,0 +1,66 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.common.AuthProvider +import com.tangem.datasource.utils.RequestHeader +import com.tangem.utils.Provider +import com.tangem.utils.ProviderSuspend +import com.tangem.utils.info.AppInfoProvider +import com.tangem.utils.version.AppVersionProvider + +/** + * Gasless transactions [ApiConfig] + */ +internal class GaslessTxService( + private val authProvider: AuthProvider, + private val appVersionProvider: AppVersionProvider, + private val appInfoProvider: AppInfoProvider, +) : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() + + override val environmentConfigs: List = listOf( + createProdEnvironment(), + createDevEnvironment(), + ) + + private fun getInitialEnvironment(): ApiEnvironment { + return when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE, + DEBUG_BUILD_TYPE, + -> ApiEnvironment.DEV + INTERNAL_BUILD_TYPE, + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + } + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = PROD_BASE_URL, + headers = createHeaders(ApiEnvironment.PROD), + ) + + private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = DEV_BASE_URL, + headers = createHeaders(ApiEnvironment.DEV), + ) + + private fun createHeaders(environment: ApiEnvironment) = buildMap { + putAll(RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) + put( + key = "Authorization", + value = ProviderSuspend { + "Bearer ${authProvider.getGaslessServiceApiKey(Provider { environment }).invoke()}" + }, + ) + } + + private companion object { + private const val PROD_BASE_URL = "https://api.tangem.org/" + private const val DEV_BASE_URL = "[REDACTED_ENV_URL]" + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt new file mode 100644 index 0000000000..0413f4ac56 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApi.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.api.gasless + +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.gasless.models.GaslessServiceResponse +import com.tangem.datasource.api.gasless.models.GaslessSupportedTokens +import retrofit2.http.GET + +interface GaslessTxServiceApi { + + @GET("api/v1/tokens") + suspend fun getSupportedTokens(): ApiResponse> +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessServiceResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessServiceResponse.kt new file mode 100644 index 0000000000..a439b9e05a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessServiceResponse.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaslessServiceResponse( + @Json(name = "result") val result: T, + @Json(name = "success") val isSuccess: Boolean, + @Json(name = "timestamp") val timestamp: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSupportedTokens.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSupportedTokens.kt new file mode 100644 index 0000000000..21ef002c64 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessSupportedTokens.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaslessSupportedTokens( + @Json(name = "tokens") val tokens: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTokenDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTokenDTO.kt new file mode 100644 index 0000000000..e107fdcc83 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTokenDTO.kt @@ -0,0 +1,14 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaslessTokenDTO( + @Json(name = "tokenAddress") val tokenAddress: String, + @Json(name = "tokenSymbol") val tokenSymbol: String, + @Json(name = "tokenName") val tokenName: String, + @Json(name = "decimals") val decimals: Int, + @Json(name = "chainId") val chainId: Int, + @Json(name = "chain") val chain: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index d3f942e798..8624353f33 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -15,6 +15,7 @@ import com.tangem.datasource.api.moonpay.MoonPayApi import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.api.onramp.OnrampApi import com.tangem.datasource.api.ethpool.P2PEthPoolApi +import com.tangem.datasource.api.gasless.GaslessTxServiceApi import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.TangemPayAuthApi import com.tangem.datasource.api.stakekit.StakeKitApi @@ -188,4 +189,13 @@ internal object NetworkModule { applyTimeoutAnnotations = false, ) } + + @Provides + @Singleton + fun provideGaslessTxServiceApi(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApi { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.GaslessTxService, + applyTimeoutAnnotations = false, + ) + } } \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt index d92d9ff26e..1f664d53c8 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt @@ -75,6 +75,11 @@ class ApiConfigTest { ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk()) ApiConfig.ID.News -> News(authProvider = appAuthProvider) ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = mockk()) + ApiConfig.ID.GaslessTxService -> GaslessTxService( + authProvider = appAuthProvider, + appVersionProvider = mockk(), + appInfoProvider = mockk(), + ) } } } diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt index 18c51f8e49..7a7a84ca41 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockEnvironmentConfigStorage.kt @@ -33,6 +33,7 @@ internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage { const val BLOCK_AID_API_KEY = "block_aid_api_key" const val TANGEM_API_KEY = "tangem_api_key" const val TANGEM_API_KEY_DEV = "tangem_api_key_dev" + const val TANGEM_GASLESS_API_KEY = "tangem_gasless_api_key" const val TANGEM_API_KEY_STAGE = "tangem_api_key_stage" const val YIELD_MODULE_KEY = "yield_module_api_key" const val YIELD_MODULE_KEY_DEV = "yield_module_api_key_dev" diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index 29e697fc8a..22ff9ab8fb 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -12,6 +12,7 @@ import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_ import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD_TYPE import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.BLOCK_AID_API_KEY import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_API_KEY +import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_GASLESS_API_KEY import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.lib.auth.P2PEthPoolAuthProvider @@ -45,6 +46,7 @@ internal class ProdApiConfigsManagerTest { private val appAuthProvider = mockk() private val appInfoProvider = mockk() private val tangemApiKeyProvider = mockk>() + private val tangemGaslessApiKeyProvider = mockk>() private lateinit var manager: ProdApiConfigsManager @@ -63,6 +65,8 @@ internal class ProdApiConfigsManagerTest { every { stakeKitAuthProvider.getApiKey() } returns STAKE_KIT_API_KEY every { p2pEthPoolAuthProvider.getApiKey() } returns P2P_API_KEY every { appAuthProvider.getApiKey(any()) } returns tangemApiKeyProvider + every { appAuthProvider.getGaslessServiceApiKey(any()) } returns tangemGaslessApiKeyProvider + coEvery { tangemGaslessApiKeyProvider.invoke() } returns TANGEM_GASLESS_API_KEY coEvery { tangemApiKeyProvider.invoke() } returns TANGEM_API_KEY coEvery { appAuthProvider.getCardId() } returns APP_CARD_ID coEvery { appAuthProvider.getCardPublicKey() } returns APP_CARD_PUBLIC_KEY @@ -117,6 +121,11 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider) ApiConfig.ID.News -> News(authProvider = appAuthProvider) ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = appVersionProvider) + ApiConfig.ID.GaslessTxService -> GaslessTxService( + authProvider = appAuthProvider, + appVersionProvider = appVersionProvider, + appInfoProvider = appInfoProvider, + ) } } } @@ -133,6 +142,7 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.P2PEthPool -> createP2PModel() ApiConfig.ID.News -> createNewsModel() ApiConfig.ID.TangemPayAuth -> createTangemPayAuthModel() + ApiConfig.ID.GaslessTxService -> createGaslessTxServiceModel() } } @@ -274,6 +284,37 @@ internal class ProdApiConfigsManagerTest { ) } + private fun createGaslessTxServiceModel(): TestModel { + val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) { + MOCKED_BUILD_TYPE, + DEBUG_BUILD_TYPE, + -> ApiEnvironment.DEV to "[REDACTED_ENV_URL]" + INTERNAL_BUILD_TYPE, + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD to "https://tangem.com/" + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + return TestModel( + id = ApiConfig.ID.GaslessTxService, + expected = ApiEnvironmentConfig( + environment = environment, + baseUrl = baseUrl, + headers = mapOf( + "Authorization" to ProviderSuspend { "Bearer $TANGEM_GASLESS_API_KEY" }, + "version" to ProviderSuspend { VERSION_NAME }, + "platform" to ProviderSuspend { "android" }, + "system_version" to ProviderSuspend { "Android 16" }, + "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "timezone" to ProviderSuspend { + TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() + }, + "device" to ProviderSuspend { "${Build.MANUFACTURER} ${Build.MODEL}".checkHeaderValueOrEmpty() }, + ), + ), + ) + } + private fun createBlockAidSdkModel(): TestModel { return TestModel( id = ApiConfig.ID.BlockAid, diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt new file mode 100644 index 0000000000..ae2209f795 --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -0,0 +1,73 @@ +package com.tangem.data.transaction + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.data.transaction.converters.GaslessTokenDtoToCryptoCurrencyConverter +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.gasless.GaslessTxServiceApi +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import java.math.BigInteger + +class DefaultGaslessTransactionRepository( + private val gaslessTxServiceApi: GaslessTxServiceApi, + private val coroutineDispatcherProvider: CoroutineDispatcherProvider, +) : GaslessTransactionRepository { + + private val gaslessTokenDtoToCryptoCurrency = GaslessTokenDtoToCryptoCurrencyConverter() + private val supportedTokensState = MutableStateFlow?>(null) + + override fun isNetworkSupported(network: Network): Boolean { + val blockchain = Blockchain.fromNetworkId(network.backendId) ?: return false + return SUPPORTED_BLOCKCHAINS.contains(blockchain) + } + + override suspend fun getSupportedTokens(): Set { + return withContext(coroutineDispatcherProvider.io) { + val storedTokens = supportedTokensState.value + if (storedTokens != null) { + return@withContext storedTokens + } + val supportedTokensData = gaslessTxServiceApi.getSupportedTokens().getOrThrow() + if (supportedTokensData.isSuccess) { + val supportedTokens = supportedTokensData.result.tokens.map { + gaslessTokenDtoToCryptoCurrency.convert(it) + }.toSet() + // update local cache + supportedTokensState.update { supportedTokens } + return@withContext supportedTokens + } else { + error("Gasless service returned unsuccessful response") + } + } + } + + override fun getTokenFeeReceiverAddress(): String { + return TOKEN_RECEIVER_ADDRESS + } + + override fun getBaseGasForTransaction(): BigInteger { + return BASE_GAS_FOR_TRANSACTION + } + + private companion object { + + const val TOKEN_RECEIVER_ADDRESS = "0x" + + val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000") + val SUPPORTED_BLOCKCHAINS = arrayOf( + Blockchain.Ethereum, + Blockchain.BSC, + Blockchain.Base, + Blockchain.Polygon, + Blockchain.Arbitrum, + Blockchain.XDC, + Blockchain.Optimism, + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/converters/GaslessTokenDtoToCryptoCurrencyConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/converters/GaslessTokenDtoToCryptoCurrencyConverter.kt new file mode 100644 index 0000000000..e6bff10c5f --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/converters/GaslessTokenDtoToCryptoCurrencyConverter.kt @@ -0,0 +1,12 @@ +package com.tangem.data.transaction.converters + +import com.tangem.datasource.api.gasless.models.GaslessTokenDTO +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.utils.converter.Converter + +internal class GaslessTokenDtoToCryptoCurrencyConverter : Converter { + + override fun convert(value: GaslessTokenDTO): CryptoCurrency { + TODO("implement conversion logic here") + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt index d8cee83001..2f066e08f2 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt @@ -8,7 +8,7 @@ interface GaslessTransactionRepository { fun isNetworkSupported(network: Network): Boolean - fun getSupportedTokens(): Set + suspend fun getSupportedTokens(): Set fun getTokenFeeReceiverAddress(): String diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt index d46ccc06c3..c170b631c8 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -72,7 +72,9 @@ class GetAvailableFeeTokensUseCase( } ?: raiseIllegalStateError("no native currency found") } - private fun getGaslessTokens(userCurrenciesStatuses: List): List { + private suspend fun getGaslessTokens( + userCurrenciesStatuses: List, + ): List { val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens() return userCurrenciesStatuses .asSequence()