Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-06 11:22:18 +05:00
parent 51c7e4e10b
commit 173ee13120
21 changed files with 155 additions and 44 deletions

View file

@ -63,6 +63,12 @@ internal class YieldSupply(
) )
private fun createHeaders(apiEnvironment: ApiEnvironment) = buildMap { private fun createHeaders(apiEnvironment: ApiEnvironment) = buildMap {
put(
key = "Authorization",
value = ProviderSuspend {
"Bearer " + environmentConfigStorage.getConfigSync().yieldModuleApiKey
},
)
put(key = "api-key", value = ProviderSuspend { getApiKey(apiEnvironment) }) put(key = "api-key", value = ProviderSuspend { getApiKey(apiEnvironment) })
putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values)
putAll(from = RequestHeader.AuthenticationHeader(authProvider).values) putAll(from = RequestHeader.AuthenticationHeader(authProvider).values)

View file

@ -2,19 +2,42 @@ package com.tangem.datasource.api.tangemTech
import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse
import com.tangem.datasource.api.tangemTech.models.YieldModuleStatusResponse
import com.tangem.datasource.api.tangemTech.models.YieldSupplyChangeTokenStatusBody
import com.tangem.datasource.api.tangemTech.models.YieldTokenStatusResponse import com.tangem.datasource.api.tangemTech.models.YieldTokenStatusResponse
import com.tangem.datasource.api.tangemTech.models.YieldTokenChartResponse import com.tangem.datasource.api.tangemTech.models.YieldTokenChartResponse
import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query
interface YieldSupplyApi { interface YieldSupplyApi {
@GET("api/v1/yield/markets") @GET("api/v1/yield/markets")
suspend fun getYieldMarkets(): ApiResponse<YieldMarketsResponse> suspend fun getYieldMarkets(@Query("chainId") chainId: Int? = null): ApiResponse<YieldMarketsResponse>
@GET("api/v1/yield/token/{tokenAddress}") @GET("api/v1/yield/token/{chainId}/{tokenAddress}")
suspend fun getYieldTokenStatus(@Path("tokenAddress") tokenAddress: String): ApiResponse<YieldTokenStatusResponse> suspend fun getYieldTokenStatus(
@Path("chainId") chainId: Int,
@Path("tokenAddress") tokenAddress: String,
): ApiResponse<YieldTokenStatusResponse>
@GET("api/v1/yield/token/{tokenAddress}/chart") @GET("api/v1/yield/token/{chainId}/{tokenAddress}/chart")
suspend fun getYieldTokenChart(@Path("tokenAddress") tokenAddress: String): ApiResponse<YieldTokenChartResponse> suspend fun getYieldTokenChart(
@Path("chainId") chainId: Int,
@Path("tokenAddress") tokenAddress: String,
@Query("window") window: String? = null,
@Query("bucketSizeDays") bucketSizeDays: Int? = null,
): ApiResponse<YieldTokenChartResponse>
@POST("api/v1/module/activate")
suspend fun activateYieldModule(
@Body body: YieldSupplyChangeTokenStatusBody,
): ApiResponse<YieldModuleStatusResponse>
@POST("api/v1/module/deactivate")
suspend fun deactivateYieldModule(
@Body body: YieldSupplyChangeTokenStatusBody,
): ApiResponse<YieldModuleStatusResponse>
} }

View file

@ -12,10 +12,11 @@ data class YieldMarketsResponse(
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class MarketDto( data class MarketDto(
@Json(name = "tokenAddress") val tokenAddress: String, @Json(name = "tokenAddress") val tokenAddress: String? = null,
@Json(name = "tokenSymbol") val tokenSymbol: String, @Json(name = "tokenSymbol") val tokenSymbol: String? = null,
@Json(name = "tokenName") val tokenName: String, @Json(name = "tokenName") val tokenName: String? = null,
@Json(name = "apy") val apy: BigDecimal, @Json(name = "apy") val apy: BigDecimal,
@Json(name = "isActive") val isActive: Boolean, @Json(name = "isActive") val isActive: Boolean,
@Json(name = "chainId") val chainId: Int? = null,
) )
} }

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class YieldModuleStatusResponse(
@Json(name = "tokenAddress") val tokenAddress: String,
@Json(name = "chainId") val chainId: Int,
@Json(name = "isActive") val isActive: Boolean,
@Json(name = "activatedAt") val activatedAt: String?,
@Json(name = "deactivatedAt") val deactivatedAt: String?,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class YieldSupplyChangeTokenStatusBody(
@Json(name = "tokenAddress") val tokenAddress: String,
@Json(name = "chainId") val chainId: Int,
)

View file

@ -6,9 +6,12 @@ import java.math.BigDecimal
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class YieldTokenStatusResponse( data class YieldTokenStatusResponse(
@Json(name = "tokenAddress") val tokenAddress: String, @Json(name = "tokenAddress") val tokenAddress: String? = null,
@Json(name = "tokenSymbol") val tokenSymbol: String, @Json(name = "tokenSymbol") val tokenSymbol: String? = null,
@Json(name = "tokenName") val tokenName: String, @Json(name = "tokenName") val tokenName: String? = null,
@Json(name = "apy") val apy: BigDecimal, @Json(name = "apy") val apy: BigDecimal? = null,
@Json(name = "isActive") val isActive: Boolean, @Json(name = "isActive") val isActive: Boolean? = null,
@Json(name = "chainId") val chainId: Int? = null,
@Json(name = "maxFeeNative") val maxFeeNative: String? = null,
@Json(name = "maxFeeUSD") val maxFeeUSD: String? = null,
) )

View file

@ -20,4 +20,5 @@ data class EnvironmentConfig(
val tangemApiKey: String? = null, val tangemApiKey: String? = null,
val tangemApiKeyDev: String? = null, val tangemApiKeyDev: String? = null,
val tangemApiKeyStage: String? = null, val tangemApiKeyStage: String? = null,
val yieldModuleApiKey: String? = null,
) )

View file

@ -29,6 +29,7 @@ internal object EnvironmentConfigConverter : Converter<EnvironmentConfigModel, E
tangemApiKey = value.tangemApiKey, tangemApiKey = value.tangemApiKey,
tangemApiKeyDev = value.tangemApiKeyDev, tangemApiKeyDev = value.tangemApiKeyDev,
tangemApiKeyStage = value.tangemApiKeyStage, tangemApiKeyStage = value.tangemApiKeyStage,
yieldModuleApiKey = value.yieldModuleApiKey,
) )
} }
} }

View file

@ -44,6 +44,7 @@ class EnvironmentConfigModel(
@Json(name = "tangemApiKeyDev") val tangemApiKeyDev: String?, @Json(name = "tangemApiKeyDev") val tangemApiKeyDev: String?,
@Json(name = "tangemApiKeyStage") val tangemApiKeyStage: String?, @Json(name = "tangemApiKeyStage") val tangemApiKeyStage: String?,
@Json(name = "etherscanApiKey") val etherScanApiKey: String?, @Json(name = "etherscanApiKey") val etherScanApiKey: String?,
@Json(name = "yieldModuleApiKey") val yieldModuleApiKey: String?,
) )
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)

View file

@ -19,6 +19,7 @@ internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage {
tangemApiKey = TANGEM_API_KEY, tangemApiKey = TANGEM_API_KEY,
tangemApiKeyDev = TANGEM_API_KEY_DEV, tangemApiKeyDev = TANGEM_API_KEY_DEV,
tangemApiKeyStage = TANGEM_API_KEY_STAGE, tangemApiKeyStage = TANGEM_API_KEY_STAGE,
yieldModuleApiKey = YIELD_MODULE_KEY,
) )
override suspend fun initialize() = environmentConfig override suspend fun initialize() = environmentConfig
@ -32,5 +33,6 @@ internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage {
const val TANGEM_API_KEY = "tangem_api_key" const val TANGEM_API_KEY = "tangem_api_key"
const val TANGEM_API_KEY_DEV = "tangem_api_key_dev" const val TANGEM_API_KEY_DEV = "tangem_api_key_dev"
const val TANGEM_API_KEY_STAGE = "tangem_api_key_stage" const val TANGEM_API_KEY_STAGE = "tangem_api_key_stage"
const val YIELD_MODULE_KEY = "yield_module_api_key"
} }
} }

View file

@ -198,6 +198,7 @@ internal class ProdApiConfigsManagerTest {
environment = ApiEnvironment.PROD, environment = ApiEnvironment.PROD,
baseUrl = "https://yield.tangem.org/", baseUrl = "https://yield.tangem.org/",
headers = mapOf( headers = mapOf(
"Authorization" to ProviderSuspend { "Bearer " + MockEnvironmentConfigStorage.YIELD_MODULE_KEY },
"api-key" to ProviderSuspend { MockEnvironmentConfigStorage.TANGEM_API_KEY }, "api-key" to ProviderSuspend { MockEnvironmentConfigStorage.TANGEM_API_KEY },
"card_id" to ProviderSuspend { APP_CARD_ID }, "card_id" to ProviderSuspend { APP_CARD_ID },
"card_public_key" to ProviderSuspend { APP_CARD_PUBLIC_KEY }, "card_public_key" to ProviderSuspend { APP_CARD_PUBLIC_KEY },
@ -276,6 +277,6 @@ internal class ProdApiConfigsManagerTest {
const val EXPRESS_SESSION_ID = "express_session_id" const val EXPRESS_SESSION_ID = "express_session_id"
const val STAKE_KIT_API_KEY = "stake_kit_api_key" const val STAKE_KIT_API_KEY = "stake_kit_api_key"
const val APP_CARD_ID = "app_card_id" const val APP_CARD_ID = "app_card_id"
const val APP_CARD_PUBLIC_KEY = "app_public_key" const val APP_CARD_PUBLIC_KEY = "Bearer app_public_key"
} }
} }

View file

@ -1,17 +1,24 @@
package com.tangem.data.yield.supply package com.tangem.data.yield.supply
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.data.yield.supply.converters.YieldMarketTokenConverter import com.tangem.data.yield.supply.converters.YieldMarketTokenConverter
import com.tangem.datasource.api.tangemTech.YieldSupplyApi import com.tangem.datasource.api.tangemTech.YieldSupplyApi
import com.tangem.data.yield.supply.converters.YieldTokenStatusConverter import com.tangem.data.yield.supply.converters.YieldTokenStatusConverter
import com.tangem.data.yield.supply.converters.YieldTokenChartConverter import com.tangem.data.yield.supply.converters.YieldTokenChartConverter
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
import com.tangem.domain.yield.supply.models.YieldMarketToken import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.collections.map
internal class DefaultYieldSupplyMarketRepository( internal class DefaultYieldSupplyMarketRepository(
private val yieldSupplyApi: YieldSupplyApi, private val yieldSupplyApi: YieldSupplyApi,
@ -20,7 +27,7 @@ internal class DefaultYieldSupplyMarketRepository(
) : YieldSupplyMarketRepository { ) : YieldSupplyMarketRepository {
override suspend fun getCachedMarkets(): List<YieldMarketToken>? = withContext(dispatchers.io) { override suspend fun getCachedMarkets(): List<YieldMarketToken>? = withContext(dispatchers.io) {
store.getSyncOrNull() store.getSyncOrNull()?.enrichNetworkIds()
} }
override suspend fun updateMarkets(): List<YieldMarketToken> = withContext(dispatchers.io) { override suspend fun updateMarkets(): List<YieldMarketToken> = withContext(dispatchers.io) {
@ -30,15 +37,28 @@ internal class DefaultYieldSupplyMarketRepository(
domain domain
} }
override fun getMarketsFlow(): Flow<List<YieldMarketToken>> = store.get() override fun getMarketsFlow(): Flow<List<YieldMarketToken>> = store.get().map {
it.enrichNetworkIds()
}
override suspend fun getTokenStatus(contractAddress: String): YieldMarketToken { override suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketTokenStatus {
val response = yieldSupplyApi.getYieldTokenStatus(contractAddress).getOrThrow() val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId()
?: error("Chain id is required for evm's")
val response = yieldSupplyApi.getYieldTokenStatus(chainId, cryptoCurrencyToken.contractAddress).getOrThrow()
return YieldTokenStatusConverter.convert(response) return YieldTokenStatusConverter.convert(response)
} }
override suspend fun getTokenChart(contractAddress: String): YieldSupplyMarketChartData { override suspend fun getTokenChart(cryptoCurrencyToken: CryptoCurrency.Token): YieldSupplyMarketChartData {
val response = yieldSupplyApi.getYieldTokenChart(contractAddress).getOrThrow() val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId()
?: error("Chain id is required for evm's")
val response = yieldSupplyApi.getYieldTokenChart(chainId, cryptoCurrencyToken.contractAddress).getOrThrow()
return YieldTokenChartConverter.convert(response) return YieldTokenChartConverter.convert(response)
} }
private fun List<YieldMarketToken>.enrichNetworkIds(): List<YieldMarketToken> {
val chainIdMap = Blockchain.entries.associate { it.getChainId() to it.toNetworkId() }
return this.map { token ->
token.copy(backendId = chainIdMap[token.chainId])
}
}
} }

View file

@ -7,11 +7,10 @@ import com.tangem.utils.converter.Converter
internal object YieldMarketTokenConverter : Converter<YieldMarketsResponse.MarketDto, YieldMarketToken> { internal object YieldMarketTokenConverter : Converter<YieldMarketsResponse.MarketDto, YieldMarketToken> {
override fun convert(value: YieldMarketsResponse.MarketDto): YieldMarketToken { override fun convert(value: YieldMarketsResponse.MarketDto): YieldMarketToken {
return YieldMarketToken( return YieldMarketToken(
tokenAddress = value.tokenAddress, tokenAddress = value.tokenAddress.orEmpty(),
tokenSymbol = value.tokenSymbol,
tokenName = value.tokenName,
apy = value.apy, apy = value.apy,
isActive = value.isActive, isActive = value.isActive,
chainId = value.chainId ?: -1,
) )
} }
} }

View file

@ -1,17 +1,21 @@
package com.tangem.data.yield.supply.converters package com.tangem.data.yield.supply.converters
import com.tangem.datasource.api.tangemTech.models.YieldTokenStatusResponse import com.tangem.datasource.api.tangemTech.models.YieldTokenStatusResponse
import com.tangem.domain.yield.supply.models.YieldMarketToken import com.tangem.domain.models.serialization.SerializedBigDecimal
import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus
import com.tangem.utils.converter.Converter import com.tangem.utils.converter.Converter
internal object YieldTokenStatusConverter : Converter<YieldTokenStatusResponse, YieldMarketToken> { internal object YieldTokenStatusConverter : Converter<YieldTokenStatusResponse, YieldMarketTokenStatus> {
override fun convert(value: YieldTokenStatusResponse): YieldMarketToken { override fun convert(value: YieldTokenStatusResponse): YieldMarketTokenStatus {
return YieldMarketToken( return YieldMarketTokenStatus(
tokenAddress = value.tokenAddress, tokenAddress = value.tokenAddress.orEmpty(),
tokenSymbol = value.tokenSymbol, tokenSymbol = value.tokenSymbol.orEmpty(),
tokenName = value.tokenName, tokenName = value.tokenName.orEmpty(),
apy = value.apy, apy = value.apy ?: SerializedBigDecimal.ZERO,
isActive = value.isActive, isActive = value.isActive ?: false,
chainId = value.chainId ?: -1,
maxFeeUSD = value.maxFeeUSD.orEmpty(),
maxFeeNative = value.maxFeeNative.orEmpty(),
) )
} }
} }

View file

@ -9,8 +9,12 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class YieldMarketToken( data class YieldMarketToken(
val tokenAddress: String, val tokenAddress: String,
val tokenSymbol: String, val chainId: Int,
val tokenName: String,
val apy: SerializedBigDecimal, val apy: SerializedBigDecimal,
val isActive: Boolean, val isActive: Boolean,
) val backendId: String? = null,
) {
val yieldSupplyKey: String
get() = "${backendId}_$tokenAddress"
}

View file

@ -0,0 +1,19 @@
package com.tangem.domain.yield.supply.models
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
/**
* Domain model representing a token entry in the Yield Markets list.
*/
@Serializable
data class YieldMarketTokenStatus(
val tokenAddress: String,
val tokenSymbol: String,
val tokenName: String,
val chainId: Int,
val apy: SerializedBigDecimal,
val isActive: Boolean,
val maxFeeNative: String,
val maxFeeUSD: String,
)

View file

@ -1,6 +1,8 @@
package com.tangem.domain.yield.supply package com.tangem.domain.yield.supply
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.yield.supply.models.YieldMarketToken import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -26,11 +28,11 @@ interface YieldSupplyMarketRepository {
* Get yield token status by contract address. * Get yield token status by contract address.
*/ */
@Throws @Throws
suspend fun getTokenStatus(contractAddress: String): YieldMarketToken suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketTokenStatus
/** /**
* Get yield token APY chart by contract address. * Get yield token APY chart by contract address.
*/ */
@Throws @Throws
suspend fun getTokenChart(contractAddress: String): YieldSupplyMarketChartData suspend fun getTokenChart(cryptoCurrencyToken: CryptoCurrency.Token): YieldSupplyMarketChartData
} }

View file

@ -18,8 +18,8 @@ class YieldSupplyApyFlowUseCase(
operator fun invoke(): Flow<Map<String, String>> { operator fun invoke(): Flow<Map<String, String>> {
return yieldSupplyMarketRepository.getMarketsFlow() return yieldSupplyMarketRepository.getMarketsFlow()
.map { yieldMarketTokenList -> .map { yieldMarketTokenList ->
yieldMarketTokenList.filter { it.isActive }.associate { yieldMarketTokenList.filter { it.isActive }.associate { token ->
it.tokenAddress to it.apy.toString() token.yieldSupplyKey to token.apy.toString()
} }
} }
} }

View file

@ -3,13 +3,13 @@ package com.tangem.domain.yield.supply.usecase
import arrow.core.Either import arrow.core.Either
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.yield.supply.YieldSupplyMarketRepository import com.tangem.domain.yield.supply.YieldSupplyMarketRepository
import com.tangem.domain.yield.supply.models.YieldMarketToken import com.tangem.domain.yield.supply.models.YieldMarketTokenStatus
class YieldSupplyGetTokenStatusUseCase( class YieldSupplyGetTokenStatusUseCase(
private val yieldSupplyMarketRepository: YieldSupplyMarketRepository, private val yieldSupplyMarketRepository: YieldSupplyMarketRepository,
) { ) {
suspend operator fun invoke(token: CryptoCurrency.Token): Either<Throwable, YieldMarketToken> = Either.catch { suspend operator fun invoke(token: CryptoCurrency.Token): Either<Throwable, YieldMarketTokenStatus> = Either.catch {
yieldSupplyMarketRepository.getTokenStatus(token.contractAddress) yieldSupplyMarketRepository.getTokenStatus(token)
} }
} }

View file

@ -213,7 +213,8 @@ internal class TokenListStateConverter(
?.contractAddress ?.contractAddress
?.lowercase() ?: return null ?.lowercase() ?: return null
return apyMap[contract] val yieldSupplyKey = "${cryptoCurrencyStatus.currency.network.backendId}_$contract"
return apyMap[yieldSupplyKey]
} }
private fun getOrganizeTokensButtonState(tokenList: TokenList): WalletOrganizeTokensButtonConfig? { private fun getOrganizeTokensButtonState(tokenList: TokenList): WalletOrganizeTokensButtonConfig? {

View file

@ -110,7 +110,7 @@ internal class YieldSupplyModel @Inject constructor(
YieldSupplyUM.Initial( YieldSupplyUM.Initial(
title = resourceReference( title = resourceReference(
id = R.string.yield_module_token_details_earn_notification_title, id = R.string.yield_module_token_details_earn_notification_title,
formatArgs = wrappedList("5.1"), formatArgs = wrappedList(tokenStatus.apy),
), ),
onClick = ::onStartEarningClick, onClick = ::onStartEarningClick,
) )