Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-05 13:10:02 +03:00
parent d9a539b64d
commit d68ad52a45
16 changed files with 278 additions and 2 deletions

View file

@ -59,6 +59,22 @@ internal class DefaultAuthProvider(
}
}
override fun getGaslessServiceApiKey(apiEnvironment: Provider<ApiEnvironment>): ProviderSuspend<String> {
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<UserWallet> {
return if (shouldUseNewListRepository) {
userWalletsListRepository.userWalletsSync()

View file

@ -18,6 +18,8 @@ interface AuthProvider {
fun getApiKey(apiEnvironment: Provider<ApiEnvironment>): ProviderSuspend<String>
fun getGaslessServiceApiKey(apiEnvironment: Provider<ApiEnvironment>): ProviderSuspend<String>
/**
* Returns map where keys(cardId) associated with cardPublicKey
*/

View file

@ -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
}
}

View file

@ -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<ApiEnvironmentConfig> = 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]"
}
}

View file

@ -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<GaslessServiceResponse<GaslessSupportedTokens>>
}

View file

@ -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<T>(
@Json(name = "result") val result: T,
@Json(name = "success") val isSuccess: Boolean,
@Json(name = "timestamp") val timestamp: String,
)

View file

@ -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<GaslessTokenDTO>,
)

View file

@ -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,
)

View file

@ -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,
)
}
}

View file

@ -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(),
)
}
}
}

View file

@ -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"

View file

@ -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<AuthProvider>()
private val appInfoProvider = mockk<AppInfoProvider>()
private val tangemApiKeyProvider = mockk<ProviderSuspend<String>>()
private val tangemGaslessApiKeyProvider = mockk<ProviderSuspend<String>>()
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,

View file

@ -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<Set<CryptoCurrency>?>(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<CryptoCurrency> {
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,
)
}
}

View file

@ -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<GaslessTokenDTO, CryptoCurrency> {
override fun convert(value: GaslessTokenDTO): CryptoCurrency {
TODO("implement conversion logic here")
}
}

View file

@ -8,7 +8,7 @@ interface GaslessTransactionRepository {
fun isNetworkSupported(network: Network): Boolean
fun getSupportedTokens(): Set<CryptoCurrency>
suspend fun getSupportedTokens(): Set<CryptoCurrency>
fun getTokenFeeReceiverAddress(): String

View file

@ -72,7 +72,9 @@ class GetAvailableFeeTokensUseCase(
} ?: raiseIllegalStateError("no native currency found")
}
private fun getGaslessTokens(userCurrenciesStatuses: List<CryptoCurrencyStatus>): List<CryptoCurrencyStatus> {
private suspend fun getGaslessTokens(
userCurrenciesStatuses: List<CryptoCurrencyStatus>,
): List<CryptoCurrencyStatus> {
val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens()
return userCurrenciesStatuses
.asSequence()