Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-17 12:23:30 +03:00
commit 75802c1551
16 changed files with 365 additions and 0 deletions

View file

@ -167,6 +167,7 @@ dependencies {
implementation(projects.domain.walletManager)
implementation(projects.domain.walletManager.models)
implementation(projects.domain.yieldSupply)
implementation(projects.domain.polymarket)
implementation(projects.domain.promo)
implementation(projects.domain.blockaid)
implementation(projects.domain.hotWallet)
@ -232,6 +233,7 @@ dependencies {
implementation(projects.data.swap)
implementation(projects.data.walletManager)
implementation(projects.data.yieldSupply)
implementation(projects.data.polymarket)
implementation(projects.data.promo)
implementation(projects.data.hotWallet)
implementation(projects.data.news)

View file

@ -0,0 +1,20 @@
package com.tangem.tap.di.domain
import com.tangem.domain.polymarket.PolymarketRepository
import com.tangem.domain.polymarket.usecase.GetPolymarketEventsUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object PolymarketDomainModule {
@Provides
@Singleton
fun provideGetPolymarketEventsUseCase(polymarketRepository: PolymarketRepository): GetPolymarketEventsUseCase {
return GetPolymarketEventsUseCase(polymarketRepository = polymarketRepository)
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.api.polymarket
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.polymarket.models.PolymarketEventsResponse
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Polymarket predictions BFF Discovery API (`api/predictions/v1`).
*
* Provided in [com.tangem.datasource.di.NetworkModule] via the [ApiConfig.ID.Predictions] config.
*/
interface PolymarketApi {
/**
* Discovery feed: paginated prediction events, each with its top active markets.
*
* @param limit page size (BFF default 20)
* @param cursor keyset pagination cursor; `null` for the first page
*/
@GET("api/predictions/v1/events")
suspend fun getEvents(
@Query("limit") limit: Int,
@Query("cursor") cursor: String?,
): ApiResponse<PolymarketEventsResponse>
}

View file

@ -0,0 +1,45 @@
package com.tangem.datasource.api.polymarket.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response of `GET /api/predictions/v1/events` (BFF `EventsPageResponse`).
*/
@JsonClass(generateAdapter = true)
data class PolymarketEventsResponse(
@Json(name = "events") val events: List<PolymarketEventDto>,
@Json(name = "cursor") val cursor: String?,
@Json(name = "hasNext") val hasNext: Boolean,
)
/** BFF `Event`. Only the fields consumed by the app are declared; unknown JSON is ignored. */
@JsonClass(generateAdapter = true)
data class PolymarketEventDto(
@Json(name = "eventId") val eventId: String,
@Json(name = "slug") val slug: String,
@Json(name = "title") val title: String,
@Json(name = "icon") val icon: String?,
@Json(name = "image") val image: String?,
@Json(name = "volume") val volume: Double?,
@Json(name = "totalMarketsCount") val totalMarketsCount: Int,
@Json(name = "markets") val markets: List<PolymarketMarketDto>,
)
/** BFF `Market` (reduced preview inside an [PolymarketEventDto]). */
@JsonClass(generateAdapter = true)
data class PolymarketMarketDto(
@Json(name = "id") val id: String,
@Json(name = "question") val question: String,
@Json(name = "icon") val icon: String?,
@Json(name = "volume") val volume: Double?,
@Json(name = "outcomes") val outcomes: List<PolymarketOutcomeDto>,
)
/** BFF `OutcomeSummary`. */
@JsonClass(generateAdapter = true)
data class PolymarketOutcomeDto(
@Json(name = "assetId") val assetId: String,
@Json(name = "label") val label: String,
@Json(name = "probability") val probability: Double?,
)

View file

@ -22,6 +22,7 @@ import com.tangem.datasource.api.gasless.GaslessTxServiceApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.TangemPayAuthApi
import com.tangem.datasource.api.polymarket.PolymarketApi
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
@ -36,6 +37,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Suppress("TooManyFunctions")
@Module
@InstallIn(SingletonComponent::class)
internal object NetworkModule {
@ -235,6 +237,17 @@ internal object NetworkModule {
)
}
@Provides
@Singleton
fun providePolymarketApi(retrofitApiBuilder: RetrofitApiBuilder): PolymarketApi {
// Polymarket BFF Discovery lives on the main Tangem gateway — reuse the TangemTech config.
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemTech,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@Provides
@Singleton
fun provideAuthApi(retrofitApiBuilder: RetrofitApiBuilder): AuthApi {

View file

@ -0,0 +1,40 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.data.polymarket"
}
dependencies {
// region Kotlin
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Core
api(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Domain
api(projects.domain.polymarket)
// endregion
// region tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
// endregion
}

View file

@ -0,0 +1,36 @@
package com.tangem.data.polymarket
import arrow.core.Either
import com.tangem.data.polymarket.converter.PolymarketEventConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.polymarket.PolymarketApi
import com.tangem.domain.core.error.DataError
import com.tangem.domain.polymarket.PolymarketRepository
import com.tangem.domain.polymarket.model.PolymarketEvent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class DefaultPolymarketRepository @Inject constructor(
private val polymarketApi: PolymarketApi,
private val eventConverter: PolymarketEventConverter,
private val dispatchers: CoroutineDispatcherProvider,
) : PolymarketRepository {
override suspend fun getEvents(): Either<DataError, List<PolymarketEvent>> = withContext(dispatchers.io) {
Either.catch {
polymarketApi.getEvents(limit = DEFAULT_LIMIT, cursor = null)
.getOrThrow()
.events
.map(eventConverter::convert)
}.mapLeft {
// DataError exposes only NoInternetConnection as a network failure.
DataError.NetworkError.NoInternetConnection
}
}
private companion object {
const val DEFAULT_LIMIT = 20
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.data.polymarket.converter
import com.tangem.datasource.api.polymarket.models.PolymarketEventDto
import com.tangem.datasource.api.polymarket.models.PolymarketMarketDto
import com.tangem.datasource.api.polymarket.models.PolymarketOutcomeDto
import com.tangem.domain.polymarket.model.PolymarketEvent
import com.tangem.domain.polymarket.model.PolymarketMarket
import com.tangem.domain.polymarket.model.PolymarketOutcome
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
import javax.inject.Inject
internal class PolymarketEventConverter @Inject constructor() : Converter<PolymarketEventDto, PolymarketEvent> {
override fun convert(value: PolymarketEventDto): PolymarketEvent {
return PolymarketEvent(
id = value.eventId,
slug = value.slug,
title = value.title,
iconUrl = value.icon,
imageUrl = value.image,
volume = value.volume?.let(BigDecimal::valueOf),
totalMarketsCount = value.totalMarketsCount,
markets = value.markets.map(::convertMarket),
)
}
private fun convertMarket(dto: PolymarketMarketDto): PolymarketMarket {
return PolymarketMarket(
id = dto.id,
title = dto.question,
iconUrl = dto.icon,
volume = dto.volume?.let(BigDecimal::valueOf),
outcomes = dto.outcomes.map(::convertOutcome),
)
}
private fun convertOutcome(dto: PolymarketOutcomeDto): PolymarketOutcome {
return PolymarketOutcome(
assetId = dto.assetId,
title = dto.label,
probability = dto.probability?.let(BigDecimal::valueOf),
)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.polymarket.di
import com.tangem.data.polymarket.DefaultPolymarketRepository
import com.tangem.domain.polymarket.PolymarketRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface PolymarketDataModule {
@Binds
@Singleton
fun bindPolymarketRepository(impl: DefaultPolymarketRepository): PolymarketRepository
}

View file

@ -0,0 +1,27 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
dependencies {
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
// region Other libraries
api(deps.arrow.core)
// endregion
// region Domain
api(projects.domain.core)
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.test.core)
// endregion
}

View file

@ -0,0 +1,13 @@
package com.tangem.domain.polymarket
import arrow.core.Either
import com.tangem.domain.core.error.DataError
import com.tangem.domain.polymarket.model.PolymarketEvent
interface PolymarketRepository {
/**
* Fetch the Discovery feed of prediction events (each with its top active markets).
*/
suspend fun getEvents(): Either<DataError, List<PolymarketEvent>>
}

View file

@ -0,0 +1,27 @@
package com.tangem.domain.polymarket.model
import java.math.BigDecimal
/**
* A prediction event the primary Discovery feed entity (BFF `Event`).
*
* @property id unique event id
* @property slug url-friendly event identifier
* @property title event title
* @property iconUrl optional event icon
* @property imageUrl optional event image
* @property volume total traded volume in USD, if known
* @property totalMarketsCount total number of markets in the event (may exceed [markets] size,
* since the Discovery feed carries only the top active markets)
* @property markets nested markets (top active ones for the feed, all for event details)
*/
data class PolymarketEvent(
val id: String,
val slug: String,
val title: String,
val iconUrl: String?,
val imageUrl: String?,
val volume: BigDecimal?,
val totalMarketsCount: Int,
val markets: List<PolymarketMarket>,
)

View file

@ -0,0 +1,20 @@
package com.tangem.domain.polymarket.model
import java.math.BigDecimal
/**
* A single Polymarket prediction market nested inside a [PolymarketEvent] (BFF `Market`).
*
* @property id unique market id
* @property title the market question (e.g. "Will X happen by 2026?")
* @property iconUrl optional market icon
* @property volume total traded volume in USD, if known
* @property outcomes normalized outcomes with their implied probabilities
*/
data class PolymarketMarket(
val id: String,
val title: String,
val iconUrl: String?,
val volume: BigDecimal?,
val outcomes: List<PolymarketOutcome>,
)

View file

@ -0,0 +1,16 @@
package com.tangem.domain.polymarket.model
import java.math.BigDecimal
/**
* A single normalized outcome of a [PolymarketMarket] (BFF `OutcomeSummary`).
*
* @property assetId upstream asset (clob token) id of the outcome
* @property title outcome label as provided by upstream (e.g. "Yes" / "No"), never hardcoded
* @property probability implied probability in the 0..1 range, if known
*/
data class PolymarketOutcome(
val assetId: String,
val title: String,
val probability: BigDecimal?,
)

View file

@ -0,0 +1,15 @@
package com.tangem.domain.polymarket.usecase
import arrow.core.Either
import com.tangem.domain.core.error.DataError
import com.tangem.domain.polymarket.PolymarketRepository
import com.tangem.domain.polymarket.model.PolymarketEvent
class GetPolymarketEventsUseCase(
private val polymarketRepository: PolymarketRepository,
) {
suspend operator fun invoke(): Either<DataError, List<PolymarketEvent>> {
return polymarketRepository.getEvents()
}
}

View file

@ -481,6 +481,7 @@ include(":domain:wallet-manager")
include(":domain:wallet-manager:models")
include(":domain:yield-supply")
include(":domain:yield-supply:models")
include(":domain:polymarket")
include(":domain:promo")
include(":domain:promo:models")
include(":domain:news")
@ -530,6 +531,7 @@ include(":data:swap")
include(":data:express")
include(":data:wallet-manager")
include(":data:yield-supply")
include(":data:polymarket")
include(":data:promo")
include(":data:news")
include(":data:earn")