Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-28 12:34:47 +03:00
commit 683bad0318
1424 changed files with 44090 additions and 9959 deletions

1
core/ab-tests/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,31 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
alias(deps.plugins.ksp)
id("configuration")
}
android {
namespace = "com.tangem.core.abtests"
}
dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Other libraries */
implementation(deps.timber)
/** Core modules */
implementation(projects.core.analytics.models)
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.domain.models)
/** Amplitude experiment */
implementation(deps.amplitude.experiment)
}

View file

@ -0,0 +1,40 @@
package com.tangem.core.abtests.di
import android.app.Application
import com.tangem.core.abtests.BuildConfig
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.abtests.manager.impl.AmplitudeABTestsManager
import com.tangem.core.abtests.manager.impl.StubABTestsManager
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object ABTestsManagerModule {
@Provides
@Singleton
fun provideABTestsManager(
application: Application,
environmentConfigStorage: EnvironmentConfigStorage,
dispatchers: CoroutineDispatcherProvider,
): ABTestsManager {
return if (BuildConfig.AB_TESTS_ENABLED) {
StubABTestsManager()
} else {
AmplitudeABTestsManager(
application = application,
apiKeyProvider = Provider { environmentConfigStorage.getConfigSync().amplitudeApiKey },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
}
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.core.abtests.manager
interface ABTestsManager {
fun init()
fun setUserProperties(userId: String?, batch: String?, productType: String?, firmware: String?)
fun removeUserProperties()
fun getValue(key: String, defaultValue: String): String
}

View file

@ -0,0 +1,95 @@
package com.tangem.core.abtests.manager.impl
import android.app.Application
import com.amplitude.experiment.Experiment
import com.amplitude.experiment.ExperimentClient
import com.amplitude.experiment.ExperimentConfig
import com.amplitude.experiment.ExperimentUser
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.utils.Provider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
internal class AmplitudeABTestsManager(
val application: Application,
val apiKeyProvider: Provider<String>,
val scope: CoroutineScope,
) : ABTestsManager {
private lateinit var client: ExperimentClient
override fun init() {
if (::client.isInitialized) {
Timber.w("AB Tests manager already initialized, skipping")
return
}
client = Experiment.initializeWithAmplitudeAnalytics(
application = application,
apiKey = apiKeyProvider(),
config = ExperimentConfig
.builder()
.automaticFetchOnAmplitudeIdentityChange(true)
.build(),
)
scope.launch {
try {
client.fetch().get()
val allVariants = client.all()
logAllVariants(allVariants)
} catch (exception: Exception) {
Timber.e(exception, "Failed to fetch AB test variants")
}
}
}
override fun setUserProperties(userId: String?, batch: String?, productType: String?, firmware: String?) {
val userProperties = mutableMapOf<String, Any>()
batch?.let { userProperties[AnalyticsParam.BATCH] = it }
productType?.let { userProperties[AnalyticsParam.PRODUCT_TYPE] = it }
firmware?.let { userProperties[AnalyticsParam.FIRMWARE] = it }
client.setUser(
ExperimentUser
.builder()
.userId(userId)
.userProperties(userProperties)
.build(),
)
}
override fun removeUserProperties() {
client.setUser(ExperimentUser())
}
override fun getValue(key: String, defaultValue: String): String {
return client.variant(key).value ?: defaultValue
}
private fun logAllVariants(allVariants: Map<String, com.amplitude.experiment.Variant>) {
Timber.d("=".repeat(SEPARATOR_LENGTH))
Timber.d("AB Tests: Fetched ${allVariants.size} variants")
Timber.d("=".repeat(SEPARATOR_LENGTH))
if (allVariants.isEmpty()) {
Timber.d("No variants available")
} else {
allVariants.entries.forEachIndexed { index, (key, variant) ->
Timber.d("[${index + 1}/${allVariants.size}] Key: $key")
Timber.d(" → Value: ${variant.value ?: "null"}")
Timber.d(" → Payload: ${variant.payload ?: "null"}")
Timber.d(" → Key: ${variant.key ?: "null"}")
Timber.d("-".repeat(SEPARATOR_LENGTH))
}
}
Timber.d("=".repeat(SEPARATOR_LENGTH))
}
private companion object {
const val SEPARATOR_LENGTH = 50
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.core.abtests.manager.impl
import com.tangem.core.abtests.manager.ABTestsManager
internal class StubABTestsManager : ABTestsManager {
override fun init() {
// intentionally do nothing
}
override fun setUserProperties(userId: String?, batch: String?, productType: String?, firmware: String?) {
// intentionally do nothing
}
override fun removeUserProperties() {
// intentionally do nothing
}
override fun getValue(key: String, defaultValue: String): String {
return defaultValue
}
}

View file

@ -84,6 +84,11 @@ sealed class AnalyticsParam {
data object LongTap : ScreensSources("Long Tap")
data object Markets : ScreensSources("Markets")
data object HotWallet : ScreensSources("Hot Wallet")
data object TangemPay : ScreensSources("Tangem Pay")
data object WalletSettings : ScreensSources("Wallet Settings")
data object Upgrade : ScreensSources("Upgrade")
data object HardwareWallet : ScreensSources("Hardware Wallet")
data object ImportWallet : ScreensSources("Import Wallet")
}
sealed class TxSentFrom(val value: String) {
@ -202,6 +207,17 @@ sealed class AnalyticsParam {
EMPTY("Empty"), FULL("Full")
}
enum class ProductType(val value: String) {
Note("Note"),
Twins("Twins"),
Wallet("Wallet"),
Start2Coin("Start2Coin"),
Wallet2("Wallet 2.0"),
Ring("Ring"),
Visa("VISA"),
MobileWallet("Mobile Wallet"),
}
companion object Key {
const val BLOCKCHAIN = "Blockchain"
const val TOKEN_PARAM = "Token"

View file

@ -61,9 +61,9 @@ object Analytics : GlobalAnalyticsEventHandler {
return paramsInterceptors.remove(interceptorId)
}
override fun setUserId(userWalletId: String) {
override fun setUserId(userId: String) {
analyticsScope.launch {
val userIdHash = userWalletId.hexToBytes()
val userIdHash = userId.hexToBytes()
.calculateSha256()
.toHexString()

View file

@ -1,17 +1,26 @@
package com.tangem.core.analytics.utils
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
/**
[REDACTED_AUTHOR]
*/
interface AnalyticsContextProxy {
interface TrackingContextProxy {
fun setContext(scanResponse: ScanResponse)
fun setContext(userWallet: UserWallet)
fun addContext(userWallet: UserWallet)
fun setHotWalletContext()
fun eraseContext()
fun addContext(scanResponse: ScanResponse)
fun addHotWalletContext()
fun removeContext()
}

View file

@ -49,12 +49,8 @@ dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
}
val generateFeatureToggles by tasks.registering {
@ -93,9 +89,9 @@ fun Task.generateToggles(inputFilePath: String, generatedFileName: String) {
.add("mapOf(\n")
.indent()
.apply {
entries.forEachIndexed { index, entry ->
entries.forEach { entry ->
add(entry)
if (index != entries.lastIndex) add(",\n") else add("\n")
add(",\n")
}
}
.unindent()
@ -117,5 +113,15 @@ fun Task.generateToggles(inputFilePath: String, generatedFileName: String) {
val outputPackageDir = File(outputDir, "")
outputPackageDir.mkdirs()
fileSpec.writeTo(outputPackageDir)
// Remove redundant public visibility modifiers
val generatedFile = File(outputPackageDir, "com/tangem/core/configtoggle/$generatedFileName.kt")
if (generatedFile.exists()) {
val content = generatedFile.readText()
val fixedContent = content
.replace("public object ", "object ")
.replace("public val ", "val ")
generatedFile.writeText(fixedContent)
}
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>DoubleMutabilityForCollection:DevExcludedBlockchainsManager.kt$DevExcludedBlockchainsManager$private var blockchainTogglesMap: MutableMap&lt;String, Boolean&gt; by Delegates.notNull()</ID>
<ID>DoubleMutabilityForCollection:DevFeatureTogglesManager.kt$DevFeatureTogglesManager$private var featureTogglesMap: MutableMap&lt;String, Boolean&gt; by Delegates.notNull()</ID>
<ID>Indentation:ExcludedBlockchainToggles.kt$ExcludedBlockchainToggles$ </ID>
<ID>Indentation:FeatureToggles.kt$FeatureToggles$ </ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -32,7 +32,7 @@
"version": "undefined"
},
{
"name": "scroll",
"name": "plasma",
"version": "undefined"
}
]

View file

@ -19,6 +19,10 @@
"name": "STAKING_CARDANO_ENABLED",
"version": "undefined"
},
{
"name": "STAKING_ETH_ENABLED",
"version": "undefined"
},
{
"name": "USEDESK_ENABLED",
"version": "undefined"
@ -33,7 +37,7 @@
},
{
"name": "TANGEM_PAY_ENABLED",
"version": "undefined"
"version": "5.31.0"
},
{
"name": "NEW_TOKEN_RECEIVE_ENABLED",
@ -50,5 +54,9 @@
{
"name": "ACCOUNTS_FEATURE_ENABLED",
"version": "undefined"
},
{
"name": "FEED_ENABLED",
"version": "undefined"
}
]

View file

@ -1,12 +1,12 @@
package com.tangem.core.configtoggle.manager
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.manager.ProdFeatureTogglesManagerTest.IsFeatureEnabledModel
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.test.core.ProvideTestModels
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*

View file

@ -1,10 +1,10 @@
package com.tangem.core.configtoggle.manager
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.test.core.ProvideTestModels
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*

View file

@ -90,10 +90,6 @@ dependencies {
implementation(deps.room.ktx)
ksp(deps.room.compiler)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CastNullableToNonNullableType:ApiResponseCallAdapterFactory.kt$ApiResponseCallAdapterFactory$as</ID>
<ID>MultilineLambdaItParameter:DefaultWalletManagersStore.kt$DefaultWalletManagersStore${ it.wallet.blockchain == walletManager.wallet.blockchain &amp;&amp; it.wallet.publicKey.derivationPath == walletManager.wallet.publicKey.derivationPath }</ID>
<ID>NestedScopeFunctions:PendingActionConverter.kt$PendingActionConverter$let { amount -&gt; PendingAction.PendingActionArgs.Amount( required = amount.required, minimum = amount.minimum, maximum = amount.maximum, ) }</ID>
<ID>NestedScopeFunctions:PendingActionConverter.kt$PendingActionConverter$let { duration -&gt; PendingAction.PendingActionArgs.Duration( required = duration.required, minimum = duration.minimum, maximum = duration.maximum, ) }</ID>
<ID>NestedScopeFunctions:PendingActionConverter.kt$PendingActionConverter$let { tronResource -&gt; PendingAction.PendingActionArgs.TronResource( required = tronResource.required, options = tronResource.options, ) }</ID>
<ID>NestedScopeFunctions:RetrofitApiBuilder.kt$RetrofitApiBuilder$let { withConnectTimeout(timeout = it.duration, unit = it.unit) }</ID>
<ID>NestedScopeFunctions:RetrofitApiBuilder.kt$RetrofitApiBuilder$let { withReadTimeout(timeout = it.duration, unit = it.unit) }</ID>
<ID>NestedScopeFunctions:RetrofitApiBuilder.kt$RetrofitApiBuilder$let { withWriteTimeout(timeout = it.duration, unit = it.unit) }</ID>
<ID>UnreachableCode:MockApiConfigsManager.kt$MockApiConfigsManager$apiConfigs + (apiConfig to environment)</ID>
<ID>UnreachableCode:MockApiConfigsManager.kt$MockApiConfigsManager$val apiConfig = apiConfigs.keys.firstOrNull { it.id.name == id } ?: error("Api config with id [$id] not found. Check that ApiConfig with id [$id] was provided into DI")</ID>
<ID>UnusedImports:NetworkModule.kt$import com.tangem.datasource.api.common.config.MoonPay</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -24,9 +24,13 @@ sealed class ApiConfig {
Express,
TangemTech,
StakeKit,
P2PEthPool,
TangemPay,
TangemPayAuth,
BlockAid,
YieldSupply,
MoonPay,
News,
}
private fun initializeId(): ID {
@ -34,9 +38,13 @@ sealed class ApiConfig {
is Express -> ID.Express
is TangemTech -> ID.TangemTech
is StakeKit -> ID.StakeKit
is P2PEthPool -> ID.P2PEthPool
is TangemPay -> ID.TangemPay
is TangemPayAuth -> ID.TangemPayAuth
is BlockAid -> ID.BlockAid
is YieldSupply -> ID.YieldSupply
is MoonPay -> ID.MoonPay
is News -> ID.News
}
}

View file

@ -0,0 +1,43 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
/**
* MoonPay [ApiConfig]
*/
internal class MoonPay : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createProdEnvironment(),
createMockEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
-> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createProdEnvironment(): ApiEnvironmentConfig {
return ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.moonpay.com/",
)
}
private fun createMockEnvironment(): ApiEnvironmentConfig {
return ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
)
}
}

View file

@ -0,0 +1,62 @@
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
/**
* News [ApiConfig]
[REDACTED_AUTHOR]
*/
internal class News(
private val authProvider: AuthProvider,
) : 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.TangemApiKeyHeader(
authProvider = authProvider,
apiEnvironment = Provider { environment },
).values,
)
}
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,63 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.utils.ProviderSuspend
/**
* P2P.org Ethereum Pooled Staking API configuration
*/
internal class P2PEthPool(
private val p2pAuthProvider: P2PEthPoolAuthProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createProdEnvironment(),
createTestEnvironment(),
createMockEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createProdEnvironment(): ApiEnvironmentConfig {
return ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.p2p.org/",
headers = createHeaders(),
)
}
private fun createTestEnvironment(): ApiEnvironmentConfig {
return ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "https://api-test.p2p.org/",
headers = createHeaders(),
)
}
private fun createMockEnvironment(): ApiEnvironmentConfig {
return ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
}
private fun createHeaders() = buildMap {
put(key = "Authorization", value = ProviderSuspend { "Bearer ${p2pAuthProvider.getApiKey()}" })
put(key = "accept", value = ProviderSuspend { "application/json" })
put(key = "Content-Type", value = ProviderSuspend { "application/json" })
}
}

View file

@ -31,7 +31,7 @@ internal class TangemPay(
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "[REDACTED_ENV_URL]",
baseUrl = "https://api.dev.us.paera.com/bff/",
headers = createHeaders(),
)

View file

@ -0,0 +1,53 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.version.AppVersionProvider
internal class TangemPayAuth(
private val appVersionProvider: AppVersionProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs = listOf(
createDevEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.DEV
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "https://api.dev.us.paera.com/",
headers = createHeaders(),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.us.paera.com/",
headers = createHeaders(),
)
private fun createHeaders() = mapOf(
"version" to ProviderSuspend { appVersionProvider.versionName },
"platform" to ProviderSuspend { "Android" },
)
}

View file

@ -14,10 +14,12 @@ sealed class ApiResponse<T : Any> {
* Represents a successful response from the API
*
* @property data the data returned by the API
* @property code the HTTP status code of the response
* @property headers the headers returned by the API
*/
data class Success<T : Any>(
val data: T,
val code: ApiResponseError.HttpException.Code = ApiResponseError.HttpException.Code.OK,
override val headers: Map<String, List<String>> = emptyMap(),
) : ApiResponse<T>()
@ -37,11 +39,20 @@ sealed class ApiResponse<T : Any> {
* Wraps data in a [ApiResponse.Success] instance
*
* @param data the data to wrap
* @param code the HTTP status code of the response
* @param headers the headers returned by the API
* @return a [ApiResponse.Success] instance containing the provided data
*/
internal fun <T : Any> apiSuccess(data: T, headers: Map<String, List<String>>): ApiResponse<T> {
return ApiResponse.Success(data, headers)
internal fun <T : Any> apiSuccess(
data: T,
code: ApiResponseError.HttpException.Code?,
headers: Map<String, List<String>>,
): ApiResponse<T> {
return ApiResponse.Success(
data = data,
code = code ?: ApiResponseError.HttpException.Code.OK,
headers = headers,
)
}
/**

View file

@ -18,8 +18,13 @@ sealed class ApiResponseError : Exception() {
val errorBody: String?,
) : ApiResponseError() {
// TODO: extract Code from HttpException
// region Error Codes
enum class Code(val numericCode: Int) {
// 2xx Success
OK(numericCode = 200),
CREATED(numericCode = 201),
ACCEPTED(numericCode = 202),
// 3xx Server Errors
NOT_MODIFIED(numericCode = 304),
// 4xx Server Errors

View file

@ -15,11 +15,12 @@ internal fun <T : Any> Response<T>.toSafeApiResponse(analyticsErrorHandler: Anal
val headers = headers().toMultimap()
val body = body()
val code = ApiResponseError.HttpException.Code.entries
.firstOrNull { it.numericCode == code() }
return if (isSuccessful && body != null) {
apiSuccess(data = body, headers = headers)
apiSuccess(data = body, code = code, headers = headers)
} else {
val code = ApiResponseError.HttpException.Code.entries
.firstOrNull { it.numericCode == code() }
val e = try {
if (code == null) {
ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}"))

View file

@ -0,0 +1,119 @@
package com.tangem.datasource.api.ethpool
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolDepositRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolUnstakeRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolWithdrawRequest
import com.tangem.datasource.api.ethpool.models.response.*
import retrofit2.http.*
/**
* P2P.org Ethereum Pooled Staking API client
*
* Documentation: https://docs.p2p.org/
*
* Base URL: https://api.p2p.org (prod) / https://api-test.p2p.org (testnet)
*/
interface P2PEthPoolApi {
/**
* Get list of available vaults
*
* @param network Ethereum pool network: "mainnet" or "hoodi" (testnet)
*/
@GET("api/v1/staking/pool/{network}/vaults")
suspend fun getVaults(
@Path("network") network: String = "mainnet",
): ApiResponse<P2PEthPoolResponse<P2PEthPoolVaultsResponse>>
/**
* Prepare deposit transaction
*
* Create unsigned transaction for depositing ETH into a vault.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param body Deposit parameters (delegator address, vault address, amount)
*/
@POST("api/v1/staking/pool/{network}/staking/deposit")
suspend fun createDepositTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolDepositRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolDepositResponse>>
/**
* Prepare unstake transaction
*
* Create unsigned transaction to initiate unstaking process.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param body Unstake parameters (staker public key, stake transaction hash)
*/
@POST("api/v1/staking/pool/{network}/staking/unstake")
suspend fun createUnstakeTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolUnstakeRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolUnstakeResponse>>
/**
* Prepare withdrawal transaction
*
* Create unsigned transaction to withdraw available funds from exit queue.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param body Withdrawal parameters (staker address)
*/
@POST("api/v1/staking/pool/{network}/staking/withdraw")
suspend fun createWithdrawTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolWithdrawRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolWithdrawResponse>>
/**
* Broadcast signed transaction
*
* Submit a signed transaction to the blockchain network.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param body Signed transaction in hexadecimal format
*/
@POST("api/v1/staking/pool/{network}/transaction/send")
suspend fun broadcastTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolBroadcastRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolBroadcastResponse>>
/**
* Get account summary
*
* Retrieve staking balance, rewards, and exit queue information for a specific account and vault.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param delegatorAddress Account address that initiated staking
* @param vaultAddress Ethereum address of the vault
*/
@GET("api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}")
suspend fun getAccountInfo(
@Path("network") network: String,
@Path("delegatorAddress") delegatorAddress: String,
@Path("vaultAddress") vaultAddress: String,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolAccountResponse>>
/**
* Get rewards history
*
* Retrieve historical rewards data for a specific account and vault.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param delegatorAddress Account address that initiated staking
* @param vaultAddress Ethereum address of the vault
* @param period Optional period filter (30, 60, or 90 days)
*/
@GET("api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}/rewards")
suspend fun getRewards(
@Path("network") network: String,
@Path("delegatorAddress") delegatorAddress: String,
@Path("vaultAddress") vaultAddress: String,
@Query("period") period: Int? = null,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolRewardsResponse>>
}

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.ethpool.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for broadcasting signed transaction
*
* Used in: POST /api/v1/staking/pool/{network}/transaction/send
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolBroadcastRequest(
@Json(name = "signedTransaction")
val signedTransaction: String,
)

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.ethpool.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for creating deposit transaction
*
* Used in: POST /api/v1/staking/pool/{network}/staking/deposit
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolDepositRequest(
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "amount")
val amount: Double,
)

View file

@ -0,0 +1,20 @@
package com.tangem.datasource.api.ethpool.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for creating unstake transaction
*
* Used in: POST /api/v1/staking/pool/{network}/staking/unstake
*
* Note: Documentation seems to contain Bitcoin-related fields (possibly copy-paste error).
* Using as-is per specification.
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolUnstakeRequest(
@Json(name = "stakerPublicKey")
val stakerPublicKey: String,
@Json(name = "stakeTransactionHash")
val stakeTransactionHash: String,
)

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.ethpool.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for creating withdrawal transaction
*
* Used in: POST /api/v1/staking/pool/{network}/staking/withdraw
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolWithdrawRequest(
@Json(name = "stakerAddress")
val stakerAddress: String,
)

View file

@ -0,0 +1,54 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
/**
* Response for GET /api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolAccountResponse(
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "stake")
val stake: P2PEthPoolStakeDTO,
@Json(name = "availableToUnstake")
val availableToUnstake: BigDecimal,
@Json(name = "availableToWithdraw")
val availableToWithdraw: BigDecimal,
@Json(name = "exitQueue")
val exitQueue: P2PEthPoolExitQueueDTO,
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolStakeDTO(
@Json(name = "assets")
val assets: BigDecimal,
@Json(name = "totalEarnedAssets")
val totalEarnedAssets: BigDecimal,
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolExitQueueDTO(
@Json(name = "total")
val total: Double,
@Json(name = "requests")
val requests: List<P2PEthPoolExitRequestDTO>,
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolExitRequestDTO(
@Json(name = "ticket")
val ticket: String,
@Json(name = "totalAssets")
val totalAssets: Double,
@Json(name = "timestamp")
val timestamp: Long,
@Json(name = "withdrawalTimestamp")
val withdrawalTimestamp: Long,
@Json(name = "isClaimable")
val isClaimable: Boolean,
)

View file

@ -0,0 +1,41 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response for POST /api/v1/staking/pool/{network}/transaction/send
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolBroadcastResponse(
@Json(name = "hash")
val hash: String,
@Json(name = "status")
val status: P2PEthPoolTxStatusDTO,
@Json(name = "blockNumber")
val blockNumber: Int,
@Json(name = "transactionIndex")
val transactionIndex: Int,
@Json(name = "gasUsed")
val gasUsed: String,
@Json(name = "cumulativeGasUsed")
val cumulativeGasUsed: String,
@Json(name = "effectiveGasPrice")
val effectiveGasPrice: String?,
@Json(name = "from")
val from: String,
@Json(name = "to")
val to: String,
)
/**
* Transaction status from P2P API
*/
@JsonClass(generateAdapter = false)
enum class P2PEthPoolTxStatusDTO {
@Json(name = "success")
SUCCESS,
@Json(name = "failed")
FAILED,
}

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.joda.time.DateTime
/**
* Response for POST /api/v1/staking/pool/{network}/staking/deposit
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolDepositResponse(
@Json(name = "amount")
val amount: Double,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "unsignedTransaction")
val unsignedTransaction: P2PEthPoolUnsignedTxDTO,
@Json(name = "createdAt")
val createdAt: DateTime,
)

View file

@ -0,0 +1,29 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Error response structure for P2P.org API
*
* All P2P API endpoints return errors in this format
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolErrorResponse(
@Json(name = "error")
val error: P2PEthPoolErrorDetailsDTO,
@Json(name = "result")
val result: Any? = null, // null on error
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolErrorDetailsDTO(
@Json(name = "code")
val code: Int, // Error code (e.g., 127106, 101111)
@Json(name = "message")
val message: String, // Human-readable error message
@Json(name = "name")
val name: String, // Error name/type
@Json(name = "errors")
val errors: List<String>? = null, // Optional validation errors array
)

View file

@ -0,0 +1,25 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Unified response wrapper for all P2P.org API responses
*
* All P2P API endpoints return responses in this format:
* ```json
* {
* "error": null | { code, message, name, errors },
* "result": { ... } | null
* }
* ```
*
* @param T The type of the result data
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolResponse<T>(
@Json(name = "error")
val error: P2PEthPoolErrorDetailsDTO?,
@Json(name = "result")
val result: T?,
)

View file

@ -0,0 +1,31 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.joda.time.DateTime
import java.math.BigDecimal
/**
* Response for GET /api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}/rewards
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolRewardsResponse(
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "rewards")
val rewards: List<P2PEthPoolRewardDTO>,
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolRewardDTO(
@Json(name = "date")
val date: DateTime,
@Json(name = "apy")
val apy: Double,
@Json(name = "balance")
val balance: BigDecimal,
@Json(name = "rewards")
val rewards: BigDecimal,
)

View file

@ -0,0 +1,32 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
/**
* Unsigned transaction structure
*
* Used in deposit, withdraw responses
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolUnsignedTxDTO(
@Json(name = "serializeTx")
val serializeTx: String,
@Json(name = "to")
val to: String,
@Json(name = "data")
val data: String,
@Json(name = "value")
val value: String,
@Json(name = "nonce")
val nonce: Int,
@Json(name = "chainId")
val chainId: Int,
@Json(name = "gasLimit")
val gasLimit: BigDecimal,
@Json(name = "maxFeePerGas")
val maxFeePerGas: BigDecimal,
@Json(name = "maxPriorityFeePerGas")
val maxPriorityFeePerGas: BigDecimal,
)

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response for POST /api/v1/staking/pool/{network}/staking/unstake
*
* Note: Contains Bitcoin-related fields (likely documentation error).
* Using as-is per specification.
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolUnstakeResponse(
@Json(name = "stakerPublicKey")
val stakerPublicKey: String,
@Json(name = "stakeTransactionHash")
val stakeTransactionHash: String,
@Json(name = "unstakeTransactionHex")
val unstakeTransactionHex: String, // unsigned
@Json(name = "unstakeFee")
val unstakeFee: Double,
)

View file

@ -0,0 +1,59 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response for GET /api/v1/staking/pool/{network}/vaults
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolVaultsResponse(
@Json(name = "network")
val network: P2PEthPoolNetworkDTO,
@Json(name = "vaults")
val vaults: List<P2PEthPoolVaultDTO>,
)
/**
* Network identifier in P2P API
*/
@JsonClass(generateAdapter = false)
enum class P2PEthPoolNetworkDTO {
@Json(name = "mainnet")
MAINNET,
@Json(name = "hoodi")
HOODI,
}
@JsonClass(generateAdapter = true)
data class P2PEthPoolVaultDTO(
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "displayName")
val displayName: String,
@Json(name = "apy")
val apy: Double,
@Json(name = "baseApy")
val baseApy: Double,
@Json(name = "capacity")
val capacity: Double,
@Json(name = "totalAssets")
val totalAssets: Double,
@Json(name = "feePercent")
val feePercent: Double,
@Json(name = "isPrivate")
val isPrivate: Boolean,
@Json(name = "isGenesis")
val isGenesis: Boolean,
@Json(name = "isSmoothingPool")
val isSmoothingPool: Boolean,
@Json(name = "isErc20")
val isErc20: Boolean,
@Json(name = "tokenName")
val tokenName: String?,
@Json(name = "tokenSymbol")
val tokenSymbol: String?,
@Json(name = "createdAt")
val createdAt: Long,
)

View file

@ -0,0 +1,24 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.joda.time.DateTime
/**
* Response for POST /api/v1/staking/pool/{network}/staking/withdraw
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolWithdrawResponse(
@Json(name = "amount")
val amount: Double,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "unsignedTransaction")
val unsignedTransaction: P2PEthPoolUnsignedTxDTO,
@Json(name = "createdAt")
val createdAt: DateTime,
@Json(name = "tickets")
val tickets: List<String>,
)

View file

@ -1,5 +0,0 @@
package com.tangem.datasource.api.express.models
object TangemExpressValues {
const val EMPTY_CONTRACT_ADDRESS_VALUE = "0"
}

View file

@ -0,0 +1,48 @@
package com.tangem.datasource.api.moonpay
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import retrofit2.http.GET
import retrofit2.http.Query
interface MoonPayApi {
@GET("v4/ip_address/")
suspend fun getUserStatus(@Query("apiKey") moonPayApiKey: String): MoonPayUserStatus
@GET("v3/currencies/")
suspend fun getCurrencies(@Query("apiKey") moonPayApiKey: String): List<MoonPayCurrencies>
}
@JsonClass(generateAdapter = true)
data class MoonPayUserStatus(
@Json(name = "isBuyAllowed")
val isBuyAllowed: Boolean,
@Json(name = "isSellAllowed")
val isSellAllowed: Boolean,
@Json(name = "isAllowed")
val isMoonpayAllowed: Boolean,
@Json(name = "alpha3")
val countryCode: String,
@Json(name = "state")
val stateCode: String,
)
@Suppress("BooleanPropertyNaming")
@JsonClass(generateAdapter = true)
data class MoonPayCurrencies(
@Json(name = "type") val type: String,
@Json(name = "code") val code: String,
@Json(name = "supportsLiveMode") val supportsLiveMode: Boolean = false,
@Json(name = "isSuspended") val isSuspended: Boolean = true,
@Json(name = "isSupportedInUS") val isSupportedInUS: Boolean = false,
@Json(name = "isSellSupported") val isSellSupported: Boolean = false,
@Json(name = "notAllowedUSStates") val notAllowedUSStates: List<String> = emptyList(),
@Json(name = "metadata") val metadata: MoonPayCurrenciesMetadata? = null,
)
@JsonClass(generateAdapter = true)
data class MoonPayCurrenciesMetadata(
@Json(name = "contractAddress") val contractAddress: String?,
@Json(name = "networkCode") val networkCode: String?,
)

View file

@ -0,0 +1,43 @@
package com.tangem.datasource.api.news
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.news.models.response.NewsCategoriesResponse
import com.tangem.datasource.api.news.models.response.NewsDetailsResponse
import com.tangem.datasource.api.news.models.response.NewsListResponse
import com.tangem.datasource.api.news.models.response.NewsTrendingResponse
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface NewsApi {
@GET(NEWS_PATH)
suspend fun getNews(
@Query("page") page: Int? = null,
@Query("limit") limit: Int? = null,
@Query("lang") language: String? = null,
@Query("asOf") snapshot: String? = null,
@Query("tokenIds") tokenIds: List<String>? = null,
@Query("categoryIds") categoryIds: List<Int>? = null,
): ApiResponse<NewsListResponse>
@GET("$NEWS_PATH/{newsId}")
suspend fun getNewsDetails(
@Path("newsId") newsId: Int,
@Query("lang") language: String? = null,
): ApiResponse<NewsDetailsResponse>
@GET("$NEWS_PATH/trending")
suspend fun getTrendingNews(
@Query("limit") limit: Int? = null,
@Query("lang") language: String? = null,
): ApiResponse<NewsTrendingResponse>
@GET("$NEWS_PATH/categories")
suspend fun getCategories(): ApiResponse<NewsCategoriesResponse>
private companion object {
private const val NEWS_PATH = "api/v1/news"
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsArticleDto(
@Json(name = "id") val id: Int,
@Json(name = "createdAt") val createdAt: String,
@Json(name = "score") val score: Double,
@Json(name = "language") val language: String,
@Json(name = "isTrending") val isTrending: Boolean,
@Json(name = "categories") val categories: List<NewsCategoryDto>,
@Json(name = "relatedTokens") val relatedTokens: List<NewsRelatedTokenDto>,
@Json(name = "title") val title: String,
@Json(name = "newsUrl") val newsUrl: String,
)
@JsonClass(generateAdapter = true)
data class NewsCategoryDto(
@Json(name = "id") val id: Int,
@Json(name = "name") val name: String,
)
@JsonClass(generateAdapter = true)
data class NewsRelatedTokenDto(
@Json(name = "id") val id: String,
@Json(name = "symbol") val symbol: String,
@Json(name = "name") val name: String,
)
@JsonClass(generateAdapter = true)
data class NewsOriginalArticleDto(
@Json(name = "id") val id: Int,
@Json(name = "title") val title: String,
@Json(name = "sourceName") val sourceName: String,
@Json(name = "language") val language: String,
@Json(name = "publishedAt") val publishedAt: String,
@Json(name = "url") val url: String,
@Json(name = "imageUrl") val imageUrl: String? = null,
)

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsCategoriesResponse(
@Json(name = "items") val items: List<NewsCategoryDto>,
)

View file

@ -0,0 +1,20 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsDetailsResponse(
@Json(name = "id") val id: Int,
@Json(name = "createdAt") val createdAt: String,
@Json(name = "score") val score: Double,
@Json(name = "language") val language: String,
@Json(name = "isTrending") val isTrending: Boolean,
@Json(name = "categories") val categories: List<NewsCategoryDto>,
@Json(name = "relatedTokens") val relatedTokens: List<NewsRelatedTokenDto>,
@Json(name = "title") val title: String,
@Json(name = "newsUrl") val newsUrl: String,
@Json(name = "shortContent") val shortContent: String,
@Json(name = "content") val content: String,
@Json(name = "originalArticles") val originalArticles: List<NewsOriginalArticleDto>,
)

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsListResponse(
@Json(name = "meta") val meta: NewsListMetaDto,
@Json(name = "items") val items: List<NewsArticleDto>,
)
@JsonClass(generateAdapter = true)
data class NewsListMetaDto(
@Json(name = "page") val page: Int,
@Json(name = "limit") val limit: Int,
@Json(name = "total") val total: Long,
@Json(name = "hasNext") val hasNext: Boolean,
@Json(name = "asOf") val asOf: String,
)

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsTrendingResponse(
@Json(name = "meta") val meta: NewsTrendingMetaDto,
@Json(name = "items") val items: List<NewsArticleDto>,
)
@JsonClass(generateAdapter = true)
data class NewsTrendingMetaDto(
@Json(name = "limit") val limit: Int,
)

View file

@ -7,6 +7,7 @@ import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
@ -25,11 +26,6 @@ interface TangemPayApi {
@Body request: GenerateNoneByCardWalletRequest,
): ApiResponse<GenerateNonceResponse>
@POST("v1/auth/challenge")
suspend fun generateNonceByCustomerWallet(
@Body request: GenerateNonceByCustomerWalletRequest,
): ApiResponse<GenerateNonceResponse>
@POST("v1/auth/token")
suspend fun getAccessTokenByCardId(@Body request: GetAccessTokenByCardIdRequest): ApiResponse<JWTResponse>
@ -123,6 +119,12 @@ interface TangemPayApi {
@GET("v1/customer/me")
suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse<CustomerMeResponse>
@GET("v1/customer/wallets/{customer_wallet_id}")
suspend fun checkCustomerWalletId(
@Header("X-API-KEY") authHeader: String,
@Path("customer_wallet_id") customerWalletId: String,
): ApiResponse<CheckCustomerWalletResponse>
@POST("v1/deeplink/validate")
suspend fun validateDeeplink(@Body body: DeeplinkValidityRequest): ApiResponse<DeeplinkValidityResponse>
@ -146,4 +148,22 @@ interface TangemPayApi {
@Header("Authorization") authHeader: String,
@Body body: CardDetailsRequest,
): ApiResponse<CardDetailsResponse>
@PUT("v1/customer/card/pin")
suspend fun setPin(
@Header("Authorization") authHeader: String,
@Body body: SetPinRequest,
): ApiResponse<SetPinResponse>
@POST("v1/customer/card/freeze")
suspend fun freezeCard(
@Header("Authorization") authHeader: String,
@Body body: FreezeUnfreezeCardRequest,
): ApiResponse<FreezeUnfreezeCardResponse>
@POST("v1/customer/card/unfreeze")
suspend fun unfreezeCard(
@Header("Authorization") authHeader: String,
@Body body: FreezeUnfreezeCardRequest,
): ApiResponse<FreezeUnfreezeCardResponse>
}

View file

@ -0,0 +1,28 @@
package com.tangem.datasource.api.pay
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.pay.models.request.GenerateNonceByCustomerWalletRequest
import com.tangem.datasource.api.pay.models.request.GetTokenByCustomerWalletRequest
import com.tangem.datasource.api.pay.models.request.RefreshCustomerWalletAccessTokenRequest
import com.tangem.datasource.api.pay.models.response.TangemPayGenerateNonceResponse
import com.tangem.datasource.api.pay.models.response.TangemPayGetTokensResponse
import retrofit2.http.Body
import retrofit2.http.POST
interface TangemPayAuthApi {
@POST("auth/challenge")
suspend fun generateNonceByCustomerWallet(
@Body request: GenerateNonceByCustomerWalletRequest,
): ApiResponse<TangemPayGenerateNonceResponse>
@POST("auth/token")
suspend fun getTokenByCustomerWallet(
@Body request: GetTokenByCustomerWalletRequest,
): ApiResponse<TangemPayGetTokensResponse>
@POST("auth/token/refresh")
suspend fun refreshCustomerWalletAccessToken(
@Body request: RefreshCustomerWalletAccessTokenRequest,
): ApiResponse<TangemPayGetTokensResponse>
}

View file

@ -0,0 +1,7 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class FreezeUnfreezeCardRequest(@Json(name = "card_id") val cardId: String)

View file

@ -7,4 +7,5 @@ import com.squareup.moshi.JsonClass
data class GenerateNonceByCustomerWalletRequest(
@Json(name = "auth_type") val authType: String = "customer_wallet",
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
@Json(name = "customer_wallet_id") val customerWalletId: String,
)

View file

@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GetTokenByCustomerWalletRequest(
@Json(name = "auth_type") val authType: String = "customer_wallet",
@Json(name = "auth_type") val authType: String,
@Json(name = "session_id") val sessionId: String,
@Json(name = "signature") val signature: String,
@Json(name = "message_format") val messageFormat: String,

View file

@ -5,6 +5,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class RefreshCustomerWalletAccessTokenRequest(
@Json(name = "auth_type") val authType: String = "customer_wallet",
@Json(name = "auth_type") val authType: String,
@Json(name = "refresh_token") val refreshToken: String,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SetPinRequest(
@Json(name = "pin") val pin: String,
@Json(name = "session_id") val sessionId: String,
@Json(name = "iv") val iv: String,
)

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SetPinResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "result") val result: String,
)
}

View file

@ -0,0 +1,37 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class BalanceResponse(
@Json(name = "fiat") val fiat: FiatBalance,
@Json(name = "crypto") val crypto: CryptoBalance,
@Json(name = "available_for_withdrawal") val availableForWithdrawal: AvailableForWithdrawal,
)
@JsonClass(generateAdapter = true)
data class FiatBalance(
@Json(name = "currency") val currency: String,
@Json(name = "available_balance") val availableBalance: BigDecimal,
@Json(name = "credit_limit") val creditLimit: BigDecimal,
@Json(name = "pending_charges") val pendingCharges: BigDecimal,
@Json(name = "posted_charges") val postedCharges: BigDecimal,
@Json(name = "balance_due") val balanceDue: BigDecimal,
)
@JsonClass(generateAdapter = true)
data class CryptoBalance(
@Json(name = "id") val id: String,
@Json(name = "chain_id") val chainId: Int,
@Json(name = "deposit_address") val depositAddress: String,
@Json(name = "token_contract_address") val tokenContractAddress: String,
@Json(name = "balance") val balance: BigDecimal,
)
@JsonClass(generateAdapter = true)
data class AvailableForWithdrawal(
@Json(name = "amount") val amount: BigDecimal,
@Json(name = "currency") val currency: String,
)

View file

@ -1,20 +1,8 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
data class CardBalanceResponse(
@Json(name = "result") val result: Result?,
@Json(name = "result") val result: BalanceResponse?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "currency") val currency: String,
@Json(name = "available_balance") val availableBalance: BigDecimal,
@Json(name = "credit_limit") val creditLimit: BigDecimal,
@Json(name = "pending_charges") val pendingCharges: BigDecimal,
@Json(name = "posted_charges") val postedCharges: BigDecimal,
@Json(name = "balance_due") val balanceDue: BigDecimal,
)
}
)

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CheckCustomerWalletResponse(
@Json(name = "id") val id: String?,
)

View file

@ -2,7 +2,6 @@ package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class CustomerMeResponse(
@ -19,7 +18,7 @@ data class CustomerMeResponse(
@Json(name = "kyc") val kyc: Kyc?,
@Json(name = "depositAddress") val depositAddress: String?,
@Json(name = "card") val card: Card?,
@Json(name = "balance") val balance: Balance?,
@Json(name = "balance") val balance: BalanceResponse?,
)
@JsonClass(generateAdapter = true)
@ -28,10 +27,49 @@ data class CustomerMeResponse(
@Json(name = "cid") val cid: String,
@Json(name = "card_id") val cardId: String,
@Json(name = "card_wallet_address") val cardWalletAddress: String,
@Json(name = "status") val status: String,
@Json(name = "status") val status: Status,
@Json(name = "updated_at") val updatedAt: String,
@Json(name = "payment_account_id") val paymentAccountId: String,
)
) {
@JsonClass(generateAdapter = false)
enum class Status {
@Json(name = "new")
NEW,
@Json(name = "ready_for_manufacturing")
READY_FOR_MANUFACTURING,
@Json(name = "manufacturing")
MANUFACTURING,
@Json(name = "sent_to_delivery")
SENT_TO_DELIVERY,
@Json(name = "delivered")
DELIVERED,
@Json(name = "activating")
ACTIVATING,
@Json(name = "active")
ACTIVE,
@Json(name = "blocked")
BLOCKED,
@Json(name = "deactivating")
DEACTIVATING,
@Json(name = "deactivated")
DEACTIVATED,
@Json(name = "canceled")
CANCELED,
@Json(name = "unknown")
UNKNOWN,
}
}
@JsonClass(generateAdapter = true)
data class PaymentAccount(
@ -60,14 +98,4 @@ data class CustomerMeResponse(
@Json(name = "card_status") val cardStatus: String,
@Json(name = "card_number_end") val cardNumberEnd: String,
)
@JsonClass(generateAdapter = true)
data class Balance(
@Json(name = "currency") val currency: String,
@Json(name = "available_balance") val availableBalance: BigDecimal,
@Json(name = "credit_limit") val creditLimit: BigDecimal,
@Json(name = "pending_charges") val pendingCharges: BigDecimal,
@Json(name = "posted_charges") val postedCharges: BigDecimal,
@Json(name = "balance_due") val balanceDue: BigDecimal,
)
}

View file

@ -0,0 +1,31 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
class FreezeUnfreezeCardResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "order_id") val orderId: String,
@Json(name = "status") val status: Status,
)
@JsonClass(generateAdapter = false)
enum class Status {
@Json(name = "NEW")
NEW,
@Json(name = "PROCESSING")
PROCESSING,
@Json(name = "COMPLETED")
COMPLETED,
@Json(name = "CANCELED")
CANCELED,
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class TangemPayGenerateNonceResponse(
@Json(name = "nonce") val nonce: String,
@Json(name = "session_id") val sessionId: String,
)

View file

@ -0,0 +1,12 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class TangemPayGetTokensResponse(
@Json(name = "access_token") val accessToken: String,
@Json(name = "expires_at") val expiresAt: Long,
@Json(name = "refresh_token") val refreshToken: String,
@Json(name = "refresh_expires_at") val refreshExpiresAt: Long,
)

View file

@ -37,7 +37,7 @@ data class TangemPayTxHistoryResponse(
@Json(name = "memo") val memo: String? = null,
@Json(name = "receipt") val receipt: Boolean,
@Json(name = "merchant_name") val merchantName: String,
@Json(name = "merchant_category") val merchantCategory: String,
@Json(name = "merchant_category") val merchantCategory: String?,
@Json(name = "merchant_category_code") val merchantCategoryCode: String,
@Json(name = "merchant_id") val merchantId: String? = null,
@Json(name = "enriched_merchant_icon") val enrichedMerchantIcon: String? = null,

View file

@ -50,9 +50,6 @@ interface TangemTechApi {
@Body userTokens: UserTokensResponse,
): ApiResponse<Unit>
@POST("v1/user-tokens")
suspend fun markUserWallerWasCreated(@Body body: MarkUserWalletWasCreatedBody): ApiResponse<Unit>
/** Returns referral status by [walletId] */
@GET("v1/referral/{walletId}")
suspend fun getReferralStatus(@Path("walletId") walletId: String): ApiResponse<ReferralResponse>
@ -137,6 +134,9 @@ interface TangemTechApi {
@GET("v1/user-wallets/wallets/by-app/{app_id}")
suspend fun getWallets(@Path("app_id") appId: String): ApiResponse<List<WalletResponse>>
@POST("v1/user-wallets/wallets")
suspend fun createWallet(@Body body: WalletIdBody): ApiResponse<Unit>
// endregion
// promo

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.api.tangemTech.converters
import com.tangem.datasource.api.tangemTech.models.CardInfoBody
import com.tangem.datasource.api.tangemTech.models.WalletIdBody
import com.tangem.datasource.api.tangemTech.models.WalletType
import com.tangem.domain.models.wallet.UserWallet
object WalletIdBodyConverter {
fun convert(userWallet: UserWallet, publicKeys: Map<String, String>? = null): WalletIdBody {
return WalletIdBody(
walletId = userWallet.walletId.stringValue,
name = userWallet.name,
walletType = WalletType.from(userWallet),
cards = publicKeys?.map { publicKeyById ->
CardInfoBody(
cardId = publicKeyById.key,
cardPublicKey = publicKeyById.value,
)
},
)
}
}

View file

@ -1,9 +0,0 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class MarkUserWalletWasCreatedBody(
@Json(name = "user_wallet_id") val userWalletId: String,
)

View file

@ -3,6 +3,9 @@ package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.common.extensions.calculateHashCode
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse.GroupType
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse.GroupType.NONE
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse.SortType
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
@ -10,8 +13,10 @@ data class UserTokensResponse(
@Json(name = "version") val version: Int = 0,
@Json(name = "group") val group: GroupType,
@Json(name = "sort") val sort: SortType,
@Json(name = "notifyStatus") val notifyStatus: Boolean? = null,
@Json(name = "tokens") val tokens: List<Token> = emptyList(),
@Json(name = "notifyStatus") val notifyStatus: Boolean? = null,
@Json(name = "name") val walletName: String? = null,
@Json(name = "type") val walletType: WalletType? = null,
) {
@JsonClass(generateAdapter = true)
@ -68,4 +73,8 @@ data class UserTokensResponse(
@Json(name = "marketcap")
MARKETCAP,
}
}
}
fun GroupType?.orDefault(): GroupType = this ?: NONE
fun SortType?.orDefault(): SortType = this ?: SortType.MANUAL

View file

@ -4,8 +4,7 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
data class WalletBody(
@Json(name = "notifyStatus") val notifyStatus: Boolean? = null,
@Json(name = "name") val name: String? = null,
@Json(name = "type") val type: WalletType? = null,
)

View file

@ -7,5 +7,6 @@ import com.squareup.moshi.JsonClass
data class WalletIdBody(
@Json(name = "id") val walletId: String,
@Json(name = "name") val name: String,
@Json(name = "cards") val cards: List<CardInfoBody>,
@Json(name = "type") val walletType: WalletType? = null,
@Json(name = "cards") val cards: List<CardInfoBody>? = null,
)

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.domain.models.wallet.UserWallet
@JsonClass(generateAdapter = false)
enum class WalletType {
@Json(name = "card")
COLD,
@Json(name = "mobile")
HOT,
;
companion object {
fun from(userWallet: UserWallet?): WalletType? {
return when (userWallet) {
is UserWallet.Cold -> COLD
is UserWallet.Hot -> HOT
null -> null
}
}
}
}

View file

@ -15,9 +15,23 @@ data class GetWalletAccountsResponse(
@JsonClass(generateAdapter = true)
data class Wallet(
@Json(name = "version") val version: Int = 0,
@Json(name = "group") val group: GroupType,
@Json(name = "sort") val sort: SortType,
@Json(name = "version") val version: Int? = 0,
@Json(name = "group") val group: GroupType?,
@Json(name = "sort") val sort: SortType?,
@Json(name = "totalAccounts") val totalAccounts: Int,
)
}
/** Flattens the tokens from all wallet accounts into a single list */
fun GetWalletAccountsResponse.flattenTokens(): List<UserTokensResponse.Token> {
return accounts.flatMap { it.tokens.orEmpty() }
}
/** Converts the [GetWalletAccountsResponse] into a [UserTokensResponse] */
fun GetWalletAccountsResponse.toUserTokensResponse(): UserTokensResponse {
return UserTokensResponse(
group = wallet.group ?: GroupType.NONE,
sort = wallet.sort ?: SortType.MANUAL,
tokens = flattenTokens(),
)
}

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.di
import com.tangem.datasource.local.accounts.AccountTokenMigrationStore
import com.tangem.datasource.local.accounts.DefaultAccountTokenMigrationStore
import com.tangem.datasource.local.datastore.RuntimeStateStore
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 AccountsMigrationStoreModule {
@Provides
@Singleton
fun provideAccountTokenMigrationStore(): AccountTokenMigrationStore {
return DefaultAccountTokenMigrationStore(
runtimeStateStore = RuntimeStateStore(emptyMap()),
)
}
}

View file

@ -4,6 +4,7 @@ import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.config.*
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.lib.auth.StakeKitAuthProvider
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.version.AppVersionProvider
@ -39,6 +40,12 @@ internal object ApiConfigsModule {
return StakeKit(stakeKitAuthProvider)
}
@Provides
@IntoSet
fun provideP2PEthPoolConfig(p2pAuthProvider: P2PEthPoolAuthProvider): ApiConfig {
return P2PEthPool(p2pAuthProvider)
}
@Provides
@IntoSet
fun provideTangemTechConfig(
@ -51,6 +58,10 @@ internal object ApiConfigsModule {
appInfoProvider = appInfoProvider,
)
@Provides
@IntoSet
fun provideNewsConfig(authProvider: AuthProvider): ApiConfig = News(authProvider = authProvider)
@Provides
@IntoSet
fun provideYieldSupplyConfig(
@ -69,9 +80,21 @@ internal object ApiConfigsModule {
@IntoSet
fun provideTangemVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemPay(appVersionProvider)
@Provides
@IntoSet
fun provideTangemPayAuthConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemPayAuth(
appVersionProvider,
)
@Provides
@IntoSet
fun provideBlockAidConfig(environmentConfigStorage: EnvironmentConfigStorage): ApiConfig {
return BlockAid(environmentConfigStorage)
}
@Provides
@IntoSet
fun provideMoonPayConfig(): ApiConfig {
return MoonPay()
}
}

View file

@ -5,14 +5,19 @@ import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfigs
import com.tangem.datasource.api.common.config.MoonPay
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.config.managers.DevApiConfigsManager
import com.tangem.datasource.api.common.config.managers.MockApiConfigsManager
import com.tangem.datasource.api.common.config.managers.ProdApiConfigsManager
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.markets.TangemTechMarketsApi
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.pay.TangemPayApi
import com.tangem.datasource.api.pay.TangemPayAuthApi
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
@ -71,6 +76,15 @@ internal object NetworkModule {
)
}
@Provides
@Singleton
fun provideP2PEthPoolApi(retrofitApiBuilder: RetrofitApiBuilder): P2PEthPoolApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.P2PEthPool,
applyTimeoutAnnotations = false,
)
}
@Provides
@Singleton
fun provideOnrampApi(retrofitApiBuilder: RetrofitApiBuilder): OnrampApi {
@ -122,6 +136,15 @@ internal object NetworkModule {
)
}
@Provides
@Singleton
fun provideTangemPayAuthApi(retrofitApiBuilder: RetrofitApiBuilder): TangemPayAuthApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPayAuth,
applyTimeoutAnnotations = false,
)
}
@Provides
@Singleton
fun provideBlockAidApi(retrofitApiBuilder: RetrofitApiBuilder): BlockAidApi {
@ -130,4 +153,22 @@ internal object NetworkModule {
applyTimeoutAnnotations = false,
)
}
@Provides
@Singleton
fun provideMoonPayApi(retrofitApiBuilder: RetrofitApiBuilder): MoonPayApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.MoonPay,
applyTimeoutAnnotations = false,
)
}
@Provides
@Singleton
fun provideNewsApi(retrofitApiBuilder: RetrofitApiBuilder): NewsApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.News,
applyTimeoutAnnotations = false,
)
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.news.details.DefaultNewsDetailsStore
import com.tangem.datasource.local.news.details.NewsDetailsStore
import com.tangem.datasource.local.news.trending.DefaultTrendingNewsStore
import com.tangem.datasource.local.news.trending.TrendingNewsStore
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 NewsStoreModule {
@Provides
@Singleton
fun provideNewsDetailsStore(): NewsDetailsStore {
return DefaultNewsDetailsStore(store = RuntimeSharedStore())
}
@Provides
@Singleton
fun provideTrendingNewsStore(): TrendingNewsStore {
return DefaultTrendingNewsStore(store = RuntimeSharedStore())
}
}

View file

@ -8,10 +8,13 @@ import com.squareup.moshi.Moshi
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.token.DefaultP2PEthPoolVaultsStore
import com.tangem.datasource.local.token.DefaultStakingActionsStore
import com.tangem.datasource.local.token.DefaultStakingYieldsStore
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.StakingActionsStore
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.listTypes
import com.tangem.datasource.utils.mapWithStringKeyTypes
@ -73,4 +76,24 @@ internal object StakingStoreModule {
fun provideStakingActionsStore(): StakingActionsStore {
return DefaultStakingActionsStore(dataStore = RuntimeDataStore())
}
@Provides
@Singleton
fun provideP2PEthPoolVaultsStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): P2PEthPoolVaultsStore {
return DefaultP2PEthPoolVaultsStore(
dataStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = listTypes<P2PEthPoolVault>(),
defaultValue = emptyList(),
),
produceFile = { context.dataStoreFile(fileName = "p2p_eth_pool_vaults") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
),
)
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
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 TangemPayStoresModule {
@Provides
@Singleton
fun provideTangemPayCardFrozenStateStore(): TangemPayCardFrozenStateStore {
return DefaultTangemPayCardFrozenStateStore(
dataStore = RuntimeDataStore(),
)
}
}

View file

@ -1,18 +0,0 @@
package com.tangem.datasource.di.exchangeservice
import com.tangem.datasource.exchangeservice.swap.DefaultExpressServiceLoader
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
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 ExchangeServiceLoaderModule {
@Binds
@Singleton
fun bindExpressServiceLoader(defaultExpressServiceLoader: DefaultExpressServiceLoader): ExpressServiceLoader
}

View file

@ -197,6 +197,7 @@ internal class RetrofitApiBuilder @Inject constructor(
val excludedApiForLogging: Set<ApiConfig.ID> = setOf(
// ApiConfig.ID.StakeKit,
ApiConfig.ID.MoonPay,
)
}
}

View file

@ -1,91 +0,0 @@
package com.tangem.datasource.exchangeservice.swap
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.request.AssetsRequestBody
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
import com.tangem.datasource.api.express.models.response.Asset
import com.tangem.datasource.exchangeservice.swap.ExpressUtils.getRefCode
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.ExpressAssetsStore
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.core.utils.lceError
import com.tangem.domain.core.utils.lceLoading
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
typealias InitializationStatusFlow = MutableStateFlow<Lce<Throwable, List<Asset>>>
/**
* Default implementation of [ExpressServiceLoader]
*
* @property tangemExpressApi express api
* @property expressAssetsStore local storage
*
[REDACTED_AUTHOR]
*/
internal class DefaultExpressServiceLoader @Inject constructor(
private val tangemExpressApi: TangemExpressApi,
private val expressAssetsStore: ExpressAssetsStore,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : ExpressServiceLoader {
private val initializationStatuses =
MutableStateFlow<Map<UserWalletId, InitializationStatusFlow>>(value = emptyMap())
override suspend fun update(userWallet: UserWallet, userTokens: List<LeastTokenInfo>) {
withContext(dispatchers.io) {
val initializationStatus = getInitializationStatusInternal(userWallet.walletId)
try {
if (userTokens.isNotEmpty()) {
val response = tangemExpressApi.getAssets(
userWalletId = userWallet.walletId.stringValue,
refCode = getRefCode(userWallet, appPreferencesStore),
body = AssetsRequestBody(tokensList = userTokens),
).getOrThrow()
expressAssetsStore.store(userWallet.walletId, response)
initializationStatus.update { response.lceContent() }
}
} catch (e: Throwable) {
if (expressAssetsStore.getSyncOrNull(userWallet.walletId) == null) {
initializationStatus.update { e.lceError() }
}
Timber.e(e, "Unable to fetch assets for: ${userWallet.walletId.stringValue}")
}
}
}
override fun getInitializationStatus(userWalletId: UserWalletId): Flow<Lce<Throwable, List<Asset>>> {
return flow { getInitializationStatusInternal(userWalletId).collect { emit(it) } }
}
@Suppress("SuspendFunWithFlowReturnType")
private suspend fun getInitializationStatusInternal(userWalletId: UserWalletId): InitializationStatusFlow {
val initializationStatus = initializationStatuses.value[userWalletId]
if (initializationStatus != null) return initializationStatus
val cached = expressAssetsStore.getSyncOrNull(userWalletId)
val default: InitializationStatusFlow = MutableStateFlow(value = cached?.lceContent() ?: lceLoading())
initializationStatuses.update { statuses ->
statuses.toMutableMap().apply {
put(key = userWalletId, value = default)
}
}
return default
}
}

View file

@ -1,22 +0,0 @@
package com.tangem.datasource.exchangeservice.swap
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
import com.tangem.datasource.api.express.models.response.Asset
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
/**
* Express service loader
*
[REDACTED_AUTHOR]
*/
interface ExpressServiceLoader {
/** Update service using [userWallet] and [userTokens] */
suspend fun update(userWallet: UserWallet, userTokens: List<LeastTokenInfo>)
/** Get initialization status by [userWalletId] */
fun getInitializationStatus(userWalletId: UserWalletId): Flow<Lce<Throwable, List<Asset>>>
}

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.local.accounts
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
interface AccountTokenMigrationStore {
fun get(userWalletId: UserWalletId): Flow<Pair<String, String>?>
suspend fun store(userWalletId: UserWalletId, value: Pair<String, String>)
suspend fun remove(userWalletId: UserWalletId)
}

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.local.accounts
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
internal class DefaultAccountTokenMigrationStore(
private val runtimeStateStore: RuntimeStateStore<Map<UserWalletId, Pair<String, String>>>,
) : AccountTokenMigrationStore {
override fun get(userWalletId: UserWalletId): Flow<Pair<String, String>?> {
return runtimeStateStore.get().map { it[userWalletId] }
}
override suspend fun store(userWalletId: UserWalletId, value: Pair<String, String>) {
runtimeStateStore.update { it + (userWalletId to value) }
}
override suspend fun remove(userWalletId: UserWalletId) {
runtimeStateStore.update { it - userWalletId }
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.datasource.local.config.environment
import com.tangem.blockchain.common.BlockchainSdkConfig
import com.tangem.datasource.local.config.environment.models.ExpressModel
import com.tangem.datasource.local.config.environment.models.P2PKeys
data class EnvironmentConfig(
val moonPayApiKey: String = "",
@ -16,10 +17,12 @@ data class EnvironmentConfig(
val express: ExpressModel? = null,
val devExpress: ExpressModel? = null,
val stakeKitApiKey: String? = null,
val p2pApiKey: P2PKeys? = null,
val blockAidApiKey: String? = null,
val tangemApiKey: String? = null,
val tangemApiKeyDev: String? = null,
val tangemApiKeyStage: String? = null,
val yieldModuleApiKey: String? = null,
val yieldModuleApiKeyDev: String? = null,
val bffStaticToken: String? = null,
)

View file

@ -26,6 +26,10 @@ internal object BlockchainSDKConfigConverter : Converter<EnvironmentConfigModel,
apiKey = value.bscQuiknodeApiKey,
subdomain = value.bscQuiknodeSubdomain,
),
quickNodePlasmaCredentials = QuickNodeCredentials(
apiKey = value.quiknodeApiKey,
subdomain = value.quiknodeSubdomain,
),
infuraProjectId = value.infuraProjectId,
tronGridApiKey = value.tronGridApiKey,
nowNodeCredentials = NowNodeCredentials(value.nowNodesApiKey),

View file

@ -25,12 +25,14 @@ internal object EnvironmentConfigConverter : Converter<EnvironmentConfigModel, E
express = value.express,
devExpress = value.devExpress,
stakeKitApiKey = value.stakeKitApiKey,
p2pApiKey = value.p2pApiKey,
blockAidApiKey = value.blockaidApiKey,
tangemApiKey = value.tangemApiKey,
tangemApiKeyDev = value.tangemApiKeyDev,
tangemApiKeyStage = value.tangemApiKeyStage,
yieldModuleApiKey = value.yieldModuleApiKey,
yieldModuleApiKeyDev = value.yieldModuleApiKeyDev,
bffStaticToken = value.bffStaticToken,
)
}
}

View file

@ -33,6 +33,7 @@ class EnvironmentConfigModel(
@Json(name = "hederaArkhiaKey") val hederaArkhiaKey: String?,
@Json(name = "polygonScanApiKey") val polygonScanApiKey: String?,
@Json(name = "stakeKitApiKey") val stakeKitApiKey: String?,
@Json(name = "p2pApiKey") val p2pApiKey: P2PKeys?,
@Json(name = "bittensorDwellirKey") val bittensorDwellirApiKey: String?,
@Json(name = "bittensorOnfinalityKey") val bittensorOnfinalityKey: String?,
@Json(name = "koinosProApiKey") val koinosProApiKey: String?,
@ -48,6 +49,7 @@ class EnvironmentConfigModel(
@Json(name = "yieldModuleApiKeyDev") val yieldModuleApiKeyDev: String?,
@Json(name = "blinkApiKey") val blinkApiKey: String?,
@Json(name = "tatumApiKey") val tatumApiKey: String?,
@Json(name = "bffStaticToken") val bffStaticToken: String?,
)
@JsonClass(generateAdapter = true)
@ -97,6 +99,12 @@ data class TonCenterKeys(
@Json(name = "testnet") val testnet: String,
)
@JsonClass(generateAdapter = true)
data class P2PKeys(
@Json(name = "mainnet") val mainnet: String,
@Json(name = "hoodi") val hoodi: String,
)
@JsonClass(generateAdapter = true)
data class GetBlockToken(
@Json(name = "jsonRpc") val jsonRPC: String?,

View file

@ -1,3 +1,7 @@
package com.tangem.datasource.local.datastore.core
@Deprecated(
message = "Use RuntimeSharedStore instead",
replaceWith = ReplaceWith("RuntimeSharedStore"),
)
internal interface StringKeyDataStore<Value : Any> : DataStore<String, Value>

View file

@ -0,0 +1,37 @@
package com.tangem.datasource.local.news.details
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.news.DetailedArticle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
internal class DefaultNewsDetailsStore(
private val store: RuntimeSharedStore<Map<Int, DetailedArticle>>,
) : NewsDetailsStore {
override fun getAll(): Flow<List<DetailedArticle>> {
return store.get().map { it.values.toList() }
}
override suspend fun getSyncOrNull(id: Int): DetailedArticle? {
return store.getSyncOrNull()?.get(id)
}
override suspend fun store(id: Int, article: DetailedArticle) {
store.update(emptyMap()) { current ->
current + (id to article)
}
}
override suspend fun store(articles: Map<Int, DetailedArticle>) {
if (articles.isEmpty()) return
store.update(emptyMap()) { current ->
current + articles
}
}
override suspend fun clear() {
store.store(emptyMap())
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.datasource.local.news.details
import com.tangem.domain.models.news.DetailedArticle
import kotlinx.coroutines.flow.Flow
interface NewsDetailsStore {
fun getAll(): Flow<List<DetailedArticle>>
suspend fun getSyncOrNull(id: Int): DetailedArticle?
suspend fun store(id: Int, article: DetailedArticle)
suspend fun store(articles: Map<Int, DetailedArticle>)
suspend fun clear()
}

View file

@ -0,0 +1,31 @@
package com.tangem.datasource.local.news.trending
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.news.ShortArticle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private typealias TrendingCache = Map<String, List<ShortArticle>>
internal class DefaultTrendingNewsStore(
private val store: RuntimeSharedStore<TrendingCache>,
) : TrendingNewsStore {
override fun get(key: String): Flow<List<ShortArticle>> {
return store.get().map { it[key].orEmpty() }
}
override suspend fun getSyncOrNull(key: String): List<ShortArticle>? {
return store.getSyncOrNull()?.get(key)
}
override suspend fun store(key: String, value: List<ShortArticle>) {
store.update(emptyMap()) { current ->
current + (key to value)
}
}
override suspend fun clear() {
store.store(emptyMap())
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.local.news.trending
import com.tangem.domain.models.news.ShortArticle
import kotlinx.coroutines.flow.Flow
interface TrendingNewsStore {
fun get(key: String): Flow<List<ShortArticle>>
suspend fun getSyncOrNull(key: String): List<ShortArticle>?
suspend fun store(key: String, value: List<ShortArticle>)
suspend fun clear()
}

View file

@ -13,6 +13,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.USED_CARDS_INFO_K
import com.tangem.datasource.local.preferences.PreferencesKeys.USER_WAS_INTERACT_WITH_RATING_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.WAS_APPLICATION_STOPPED_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.WAS_TWINS_ONBOARDING_SHOWN
import com.tangem.domain.models.wallet.UserWalletId
/**
* All preferences keys that DataStore<Preferences> is stored.
@ -121,6 +122,8 @@ object PreferencesKeys {
val YIELD_SUPPLY_WARNINGS_STATES_KEY by lazy { stringPreferencesKey(name = "yieldSupplyWarningsStates") }
val ACCESS_CODE_SKIPPED_STATES_KEY by lazy { stringPreferencesKey(name = "accessCodeSkippedStates") }
// region Notifications
val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") }
@ -171,6 +174,12 @@ object PreferencesKeys {
fun getHotWalletUnlockDeadlineKey(attemptId: String) =
longPreferencesKey(name = "hotWalletUnlockDeadline_$attemptId")
fun getTangemPayAddToWalletKey(customerWalletAddress: String) =
booleanPreferencesKey("tangem_pay_add_to_wallet_done_key_$customerWalletAddress")
fun getTangemPayCheckCustomerByWalletId(userWalletId: UserWalletId) =
booleanPreferencesKey("tangem_pay_check_customer_by_wallet_id_$userWalletId")
// endregion
}

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.local.token
import androidx.datastore.core.DataStore
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
internal class DefaultP2PEthPoolVaultsStore(
private val dataStore: DataStore<List<P2PEthPoolVault>>,
) : P2PEthPoolVaultsStore {
override fun get(): Flow<List<P2PEthPoolVault>> {
return dataStore.data
}
override suspend fun getSync(): List<P2PEthPoolVault> {
return dataStore.data.firstOrNull().orEmpty()
}
override suspend fun store(vaults: List<P2PEthPoolVault>) {
dataStore.updateData { vaults }
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.datasource.local.token
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import kotlinx.coroutines.flow.Flow
/**
* Store for P2P Ethereum pooled staking vaults
* (similar to StakingYieldsStore for StakeKit yields)
*
* Vault is ETH-specific concept for pooled staking.
* For other blockchains, P2P may use different structures.
*/
interface P2PEthPoolVaultsStore {
/**
* Get all stored vaults as Flow
*/
fun get(): Flow<List<P2PEthPoolVault>>
/**
* Get all stored vaults synchronously
*/
suspend fun getSync(): List<P2PEthPoolVault>
/**
* Store vaults from P2P API
*/
suspend fun store(vaults: List<P2PEthPoolVault>)
}

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.local.visa
import com.tangem.datasource.local.datastore.core.StringKeyDataStore
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import kotlinx.coroutines.flow.Flow
internal class DefaultTangemPayCardFrozenStateStore(
private val dataStore: StringKeyDataStore<TangemPayCardFrozenState>,
) : TangemPayCardFrozenStateStore {
override suspend fun getSyncOrNull(key: String): TangemPayCardFrozenState? {
return dataStore.getSyncOrNull(key)
}
override fun get(key: String): Flow<TangemPayCardFrozenState> {
return dataStore.get(key)
}
override suspend fun store(key: String, value: TangemPayCardFrozenState) {
dataStore.store(key, value)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import kotlinx.coroutines.flow.Flow
interface TangemPayCardFrozenStateStore {
suspend fun getSyncOrNull(key: String): TangemPayCardFrozenState?
fun get(key: String): Flow<TangemPayCardFrozenState>
suspend fun store(key: String, value: TangemPayCardFrozenState)
}

View file

@ -1,12 +1,16 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.model.TangemPayAuthTokens
interface TangemPayStorage {
suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens)
suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String)
suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String?
suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens?
suspend fun storeAuthTokens(customerWalletAddress: String, tokens: TangemPayAuthTokens)
suspend fun getAuthTokens(customerWalletAddress: String): TangemPayAuthTokens?
suspend fun storeOrderId(customerWalletAddress: String, orderId: String)
@ -14,5 +18,11 @@ interface TangemPayStorage {
suspend fun clearOrderId(customerWalletAddress: String)
suspend fun clearAll(customerWalletAddress: String)
suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean
suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean)
suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId)
suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean?
suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String)
}

Some files were not shown because too many files have changed in this diff Show more