Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 13:39:30 +03:00
commit 92849d43c2
1407 changed files with 64092 additions and 11990 deletions

View file

@ -8,32 +8,50 @@ plugins {
}
android {
namespace = "com.tangem.lib.auth"
namespace = "com.tangem.libs.auth"
}
dependencies {
/** Core */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Kotlin
api(deps.kotlin.datetime)
api(deps.kotlin.serialization)
implementation(deps.kotlin.coroutines)
// endregion
// region Other libraries
api(deps.arrow.core)
api(deps.okHttp)
implementation(deps.moshi)
implementation(deps.retrofit)
// endregion
// region Firebase
implementation(platform(deps.firebase.bom))
implementation(deps.firebase.crashlytics)
// endregion
// region Tangem
implementation(tangemDeps.card.android)
implementation(tangemDeps.card.core)
// endregion
// region Core modules
implementation(projects.core.configToggles)
implementation(projects.core.datasource)
implementation(projects.core.utils)
// endregion
/** Tangem libraries */
implementation(tangemDeps.card.core)
implementation(tangemDeps.card.android)
/** Firebase */
implementation(platform(deps.firebase.bom))
implementation(deps.firebase.crashlytics)
/** Other */
implementation(deps.arrow.core)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(deps.test.junit5)
// region Tests
testImplementation(deps.androidx.datastore)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
// endregion
}

View file

@ -3,7 +3,9 @@ package com.tangem.lib.auth.dpop.internal
import arrow.core.None
import arrow.core.Option
import com.tangem.lib.auth.dpop.DpopProofFactory
import com.tangem.utils.annotations.RemoveWithToggle
@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED")
internal object DisabledDpopProofFactory : DpopProofFactory {
override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option<String> = None

View file

@ -19,6 +19,9 @@ sealed class AuthError(open val problem: AuthErrorResponse?) {
/** `404` — token / resource not found. */
data class NotFound(override val problem: AuthErrorResponse?) : AuthError(problem)
/** `409` — conflict / already exists (e.g. device or wallet already registered). */
data class Conflict(override val problem: AuthErrorResponse?) : AuthError(problem)
/** `429` — server-side rate limit; honour [retryAfterSeconds] before retrying. */
data class RateLimited(
val retryAfterSeconds: Int?,

View file

@ -2,8 +2,8 @@ package com.tangem.lib.auth.session
/**
* Typed failure mode of `SessionTokenRefresher.refresh()`. Distinguishes terminal failures
* (re-registration required) from transient ones (network / server) so callers can decide
* whether to retry, surface UI, or trigger deferred-registration flow.
* (re-registration required, server-side block) from transient ones (network / server) so callers
* can decide whether to retry, surface UI, or trigger deferred-registration flow.
*/
sealed class SessionRefreshError {
@ -12,10 +12,18 @@ sealed class SessionRefreshError {
/**
* Terminal `/authenticate` returned 401/403. Session store was cleared; the device must
* re-register ([REDACTED_TASK_KEY] / deferred-registration flow).
* re-register.
*/
data object SessionRevoked : SessionRefreshError()
/**
* Terminal `/refresh` returned 403 (RED tier). The device is server-side blocked;
* `/authenticate` won't help (it would also return 403). The client should not retry within
* the current session only attempt `/refresh` again on the next app launch, in case the
* server-side block was lifted.
*/
data object DeviceBlocked : SessionRefreshError()
/** Device key is not provisioned in Keystore (registration not yet run, or Keystore unavailable). */
data object DeviceKeyUnavailable : SessionRefreshError()

View file

@ -11,12 +11,13 @@ import arrow.core.Either
*
* Refresh strategy:
* 1. Call `/api/v1/auth/refresh` with the stored refresh token when it is present and unexpired.
* 2. On 401/403 from `/refresh` (revoked / replayed / RED-tier downgrade), fall back to
* full re-authentication via `/api/v1/auth/nonce/auth` + `/api/v1/auth/authenticate`
* signed by the device key.
* 3. On 401/403 from `/authenticate`, clear the session store and return
* [SessionRefreshError.SessionRevoked] the device must be re-registered (see [REDACTED_TASK_KEY]
* for the deferred-registration flag).
* 2. On 401 from `/refresh` (revoked / replayed / expired refresh token), fall back to full
* re-authentication via `/api/v1/auth/nonce/auth` + `/api/v1/auth/authenticate` signed by the
* device key.
* 3. On 403 from `/refresh` (RED tier device blocked server-side), return
* [SessionRefreshError.DeviceBlocked] without trying `/authenticate` (it would also 403).
* 4. On 401/403 from `/authenticate`, clear the session store and return
* [SessionRefreshError.SessionRevoked] the device must be re-registered.
*/
interface SessionTokenRefresher {

View file

@ -35,6 +35,7 @@ internal class AuthErrorConverter @Inject constructor() : Converter<Throwable, A
Code.UNAUTHORIZED -> AuthError.Unauthorized(problem)
Code.FORBIDDEN -> AuthError.Forbidden(problem)
Code.NOT_FOUND -> AuthError.NotFound(problem)
Code.CONFLICT -> AuthError.Conflict(problem)
Code.TOO_MANY_REQUESTS -> AuthError.RateLimited(problem?.retryAfterSeconds, problem)
else -> if (error.isServerError()) AuthError.ServerUnavailable(problem) else AuthError.Unknown(error)
}

View file

@ -1,11 +1,13 @@
package com.tangem.lib.auth.session.internal
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.either
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
import com.tangem.datasource.api.auth.models.request.RegisterApiRequest
import com.tangem.datasource.api.auth.models.request.RegisterPayload
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
@ -13,6 +15,7 @@ import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
import com.tangem.lib.auth.session.AuthError
import com.tangem.lib.auth.session.DeviceRegistrar
import com.tangem.lib.auth.session.DeviceRegistrationError
import com.tangem.lib.auth.session.SessionTokensStore
@ -89,10 +92,16 @@ internal class DefaultDeviceRegistrar(
raise(DeviceRegistrationError.SigningFailed(e))
}
val registerResponse = authApi.register(RegisterApiRequest(payload = payload, signature = signature))
when (registerResponse) {
val registerResponse = authApi.registerDevice(RegisterApiRequest(payload = payload, signature = signature))
handleRegisterResponse(registerResponse)
}
private suspend fun Raise<DeviceRegistrationError>.handleRegisterResponse(
response: ApiResponse<TokenApiResponse>,
) {
when (response) {
is ApiResponse.Success -> {
val tokens = SessionTokensConverter.convertBack(registerResponse.data)
val tokens = SessionTokensConverter.convertBack(response.data)
try {
// Keep both writes inside one catch — if the second one fails, the flag stays
// `false` and the next launch retries cleanly. Worst case: tokens are persisted
@ -106,10 +115,27 @@ internal class DefaultDeviceRegistrar(
TangemLogger.i("Device registered successfully")
}
is ApiResponse.Error -> {
val authError = errorConverter.convert(registerResponse.cause)
val authError = errorConverter.convert(response.cause)
if (authError is AuthError.Conflict) {
// Device is already registered server-side (e.g. the local flag was lost on
// reinstall). Persist the flag to stop retrying; session tokens will be minted
// on demand via /authenticate.
TangemLogger.i("Device already registered server-side (409) — marking as registered")
markRegistered(onFailureLog = "Failed to persist device-registration flag after 409")
return
}
TangemLogger.e("/register request failed: $authError")
raise(DeviceRegistrationError.Api(authError))
}
}
}
private suspend fun Raise<DeviceRegistrationError>.markRegistered(onFailureLog: String) {
try {
appPreferencesStore.store(key = PreferencesKeys.IS_DEVICE_REGISTERED_KEY, value = true)
} catch (e: Exception) {
TangemLogger.e(onFailureLog, e)
raise(DeviceRegistrationError.PersistenceFailed(e))
}
}
}

View file

@ -9,7 +9,6 @@ import com.tangem.datasource.api.auth.models.request.AuthApiRequest
import com.tangem.datasource.api.auth.models.request.AuthenticationPayload
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
import com.tangem.datasource.api.auth.models.request.RefreshApiRequest
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
@ -55,10 +54,17 @@ internal class DefaultSessionTokenRefresher(
}
if (isOwner) {
TangemLogger.i("Session refresh started (owner)")
try {
deferred.complete(runRefresh(current = store.get().getOrNull()))
val outcome = runRefresh(current = store.get().getOrNull())
outcome.fold(
ifLeft = { TangemLogger.e("Session refresh finished with error: $it") },
ifRight = { TangemLogger.i("Session refresh finished successfully") },
)
deferred.complete(outcome)
} catch (t: Throwable) {
// Propagate to every waiter — without this they'd suspend forever on `await()`.
TangemLogger.e("Session refresh threw; propagating to waiters", t)
deferred.completeExceptionally(t)
throw t
} finally {
@ -68,6 +74,8 @@ internal class DefaultSessionTokenRefresher(
mutex.withLock { inFlight = null }
}
}
} else {
TangemLogger.i("Session refresh already in-flight — joining as waiter")
}
deferred.await()
@ -78,11 +86,31 @@ internal class DefaultSessionTokenRefresher(
val isRefreshTokenValid = current?.refreshTokenExpiresAt != null && current.refreshTokenExpiresAt > now
if (current?.refreshToken != null && isRefreshTokenValid) {
TangemLogger.i("Calling /refresh with stored refresh token")
when (val result = callRefresh(current.refreshToken)) {
is RefreshOutcome.Success -> return result.tokens.right()
RefreshOutcome.Unauthenticated -> Unit // fall through to /authenticate
is RefreshOutcome.Transient -> return SessionRefreshError.Api(result.cause).left()
is RefreshOutcome.Success -> {
store.save(result.tokens)
TangemLogger.i("/refresh succeeded; session tokens persisted")
return result.tokens.right()
}
// 401: refresh token is invalid/expired/revoked/replayed but the device key is
// intact server-side → fall back to /authenticate to mint a new pair.
RefreshOutcome.RefreshTokenInvalid -> {
TangemLogger.i("/refresh returned 401 — falling back to /authenticate")
}
// 403: device is blocked server-side (RED tier). `/authenticate` would also 403,
// so don't waste the call — surface the terminal state and let the caller bail.
RefreshOutcome.DeviceBlocked -> {
TangemLogger.e("/refresh returned 403 — device is blocked server-side (terminal)")
return SessionRefreshError.DeviceBlocked.left()
}
is RefreshOutcome.Transient -> {
TangemLogger.e("/refresh failed with transient error: ${result.cause}")
return SessionRefreshError.Api(result.cause).left()
}
}
} else {
TangemLogger.i("No valid refresh token in store — proceeding directly to /authenticate")
}
return runAuthenticate()
@ -90,10 +118,19 @@ internal class DefaultSessionTokenRefresher(
private suspend fun callRefresh(refreshToken: String): RefreshOutcome {
val response = authApi.refresh(RefreshApiRequest(refreshToken = refreshToken))
return handleTokenResponse(response, clearOnUnauthenticated = false)
return when (response) {
is ApiResponse.Success -> RefreshOutcome.Success(SessionTokensConverter.convertBack(response.data))
is ApiResponse.Error -> when (val authError = errorConverter.convert(response.cause)) {
is AuthError.Unauthorized -> RefreshOutcome.RefreshTokenInvalid
is AuthError.Forbidden -> RefreshOutcome.DeviceBlocked
else -> RefreshOutcome.Transient(authError)
}
}
}
private suspend fun runAuthenticate(): Either<SessionRefreshError, SessionTokens> = either {
TangemLogger.i("Starting /authenticate")
val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull()
?: raise(SessionRefreshError.DeviceKeyUnavailable)
@ -104,6 +141,7 @@ internal class DefaultSessionTokenRefresher(
is ApiResponse.Success -> nonceResponse.data.cipheredNonce
is ApiResponse.Error -> {
val authError = errorConverter.convert(nonceResponse.cause)
TangemLogger.e("/nonce/auth request failed: $authError")
raise(SessionRefreshError.Api(authError))
}
}
@ -129,33 +167,25 @@ internal class DefaultSessionTokenRefresher(
}
val authResponse = authApi.authenticate(AuthApiRequest(payload = payload, signature = signature))
return when (val outcome = handleTokenResponse(authResponse, clearOnUnauthenticated = true)) {
is RefreshOutcome.Success -> outcome.tokens.right()
RefreshOutcome.Unauthenticated -> SessionRefreshError.SessionRevoked.left()
is RefreshOutcome.Transient -> SessionRefreshError.Api(outcome.cause).left()
}
}
private suspend fun handleTokenResponse(
response: ApiResponse<TokenApiResponse>,
clearOnUnauthenticated: Boolean,
): RefreshOutcome {
return when (response) {
when (authResponse) {
is ApiResponse.Success -> {
val tokens = SessionTokensConverter.convertBack(response.data)
val tokens = SessionTokensConverter.convertBack(authResponse.data)
store.save(tokens)
RefreshOutcome.Success(tokens)
TangemLogger.i("/authenticate succeeded; session tokens persisted")
tokens
}
is ApiResponse.Error -> {
when (val authError = errorConverter.convert(response.cause)) {
val authError = errorConverter.convert(authResponse.cause)
when (authError) {
is AuthError.Unauthorized, is AuthError.Forbidden -> {
if (clearOnUnauthenticated) {
TangemLogger.i("Session revoked: ${authError.problem?.detail ?: authError}")
store.clear()
}
RefreshOutcome.Unauthenticated
TangemLogger.i("Session revoked: ${authError.problem?.detail ?: authError}")
store.clear()
raise(SessionRefreshError.SessionRevoked)
}
else -> {
TangemLogger.e("/authenticate request failed: $authError")
raise(SessionRefreshError.Api(authError))
}
else -> RefreshOutcome.Transient(authError)
}
}
}
@ -163,7 +193,8 @@ internal class DefaultSessionTokenRefresher(
private sealed interface RefreshOutcome {
data class Success(val tokens: SessionTokens) : RefreshOutcome
data object Unauthenticated : RefreshOutcome
data object RefreshTokenInvalid : RefreshOutcome
data object DeviceBlocked : RefreshOutcome
data class Transient(val cause: AuthError) : RefreshOutcome
}
}

View file

@ -4,7 +4,9 @@ import arrow.core.Either
import arrow.core.left
import com.tangem.lib.auth.session.DeviceRegistrar
import com.tangem.lib.auth.session.DeviceRegistrationError
import com.tangem.utils.annotations.RemoveWithToggle
@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED")
internal object DisabledDeviceRegistrar : DeviceRegistrar {
override suspend fun register(): Either<DeviceRegistrationError, Unit> {

View file

@ -17,7 +17,7 @@ internal class SignedRequestPayload @Inject constructor(
private val appInfoProvider: AppInfoProvider,
) {
/** Snapshot of [appInfoProvider]'s device facts as the network DTO. `userAgent` is intentionally null. */
/** Snapshot of [appInfoProvider]'s device facts as the network DTO. */
val deviceMetadata: DeviceMetadata
get() = DeviceMetadata(
deviceModel = appInfoProvider.device,
@ -25,7 +25,7 @@ internal class SignedRequestPayload @Inject constructor(
os = appInfoProvider.platform.lowercase(),
osVersion = appInfoProvider.osVersion,
appVersion = appInfoProvider.appVersion,
userAgent = null,
userAgent = with(appInfoProvider) { "Tangem/$appVersion ($device; $platform $osVersion)" },
locale = appInfoProvider.language,
timezone = appInfoProvider.timezone,
)
@ -49,9 +49,7 @@ internal class SignedRequestPayload @Inject constructor(
/**
* Stable, newline-separated representation of the signed payload. Backend treats the bytes
* opaquely; must stay aligned with the server-side canonicalisation. Field order matches the
* declaration order of [RegisterPayload] / [AuthenticationPayload], with one exception:
* [DeviceMetadata.userAgent] is intentionally NOT included in the signed bytes (it's always
* `null` in [deviceMetadata] and the server doesn't sign it either).
* declaration order of [RegisterPayload] / [AuthenticationPayload] and [DeviceMetadata].
*/
private fun canonicalize(
devicePublicKey: String,
@ -62,12 +60,13 @@ internal class SignedRequestPayload @Inject constructor(
append(devicePublicKey).append('\n')
append(nonce).append('\n')
append(attestationToken.orEmpty()).append('\n')
append(metadata.deviceModel.orEmpty()).append('\n')
append(metadata.deviceModel).append('\n')
append(metadata.os).append('\n')
append(metadata.osVersion.orEmpty()).append('\n')
append(metadata.appVersion.orEmpty()).append('\n')
append(metadata.locale.orEmpty()).append('\n')
append(metadata.timezone.orEmpty())
append(metadata.osVersion).append('\n')
append(metadata.appVersion).append('\n')
append(metadata.userAgent).append('\n')
append(metadata.locale).append('\n')
append(metadata.timezone)
}.toByteArray(Charsets.UTF_8)
}

View file

@ -71,6 +71,14 @@ class AuthErrorConverterTest {
assertThat(result).isInstanceOf(AuthError.NotFound::class.java)
}
@Test
fun `409 is converted to Conflict`() {
val result = converter.convert(httpError(Code.CONFLICT, sampleBody))
assertThat(result).isInstanceOf(AuthError.Conflict::class.java)
assertThat((result as AuthError.Conflict).problem).isEqualTo(sampleProblem)
}
@Test
fun `429 surfaces retryAfterSeconds from problem`() {
val rateLimitBody = """

View file

@ -9,7 +9,6 @@ import arrow.core.Some
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
import com.tangem.datasource.api.auth.models.request.RegisterApiRequest
import com.tangem.datasource.api.auth.models.response.NonceApiResponse
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
@ -89,7 +88,7 @@ class DefaultDeviceRegistrarTest {
assertThat(result.isRight()).isTrue()
coVerify { authApi.requestDeviceNonce(any()) }
coVerify { authApi.register(any<RegisterApiRequest>()) }
coVerify { authApi.registerDevice(any<RegisterApiRequest>()) }
coVerify { store.save(any()) }
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isTrue()
}
@ -102,7 +101,7 @@ class DefaultDeviceRegistrarTest {
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { authApi.requestDeviceNonce(any()) }
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
coVerify(exactly = 0) { authApi.registerDevice(any<RegisterApiRequest>()) }
coVerify(exactly = 0) { store.save(any()) }
}
@ -114,7 +113,7 @@ class DefaultDeviceRegistrarTest {
assertThat(result.leftOrNull()).isEqualTo(DeviceRegistrationError.DeviceKeyUnavailable)
coVerify(exactly = 0) { authApi.requestDeviceNonce(any()) }
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
coVerify(exactly = 0) { authApi.registerDevice(any<RegisterApiRequest>()) }
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
}
@ -133,7 +132,7 @@ class DefaultDeviceRegistrarTest {
val result = registrar.register()
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.Api::class.java)
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
coVerify(exactly = 0) { authApi.registerDevice(any<RegisterApiRequest>()) }
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
}
@ -148,7 +147,7 @@ class DefaultDeviceRegistrarTest {
val result = registrar.register()
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.NonceDecryptionFailed::class.java)
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
coVerify(exactly = 0) { authApi.registerDevice(any<RegisterApiRequest>()) }
}
@Test
@ -163,7 +162,7 @@ class DefaultDeviceRegistrarTest {
val result = registrar.register()
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.SigningFailed::class.java)
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
coVerify(exactly = 0) { authApi.registerDevice(any<RegisterApiRequest>()) }
}
@Test
@ -175,7 +174,7 @@ class DefaultDeviceRegistrarTest {
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64)
@Suppress("UNCHECKED_CAST")
coEvery { authApi.register(any<RegisterApiRequest>()) } returns ApiResponse.Error(
coEvery { authApi.registerDevice(any<RegisterApiRequest>()) } returns ApiResponse.Error(
cause = ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.FORBIDDEN,
message = "already registered",
@ -190,6 +189,31 @@ class DefaultDeviceRegistrarTest {
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
}
@Test
fun `register treats 409 Conflict as success, sets flag without persisting tokens`() = runTest {
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
)
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64)
@Suppress("UNCHECKED_CAST")
coEvery { authApi.registerDevice(any<RegisterApiRequest>()) } returns ApiResponse.Error(
cause = ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.CONFLICT,
message = "device already registered",
errorBody = null,
),
) as ApiResponse<TokenApiResponse>
val result = registrar.register()
// Device is already registered server-side — no error, flag set, but no tokens minted here.
assertThat(result.isRight()).isTrue()
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isTrue()
coVerify(exactly = 0) { store.save(any()) }
}
@Test
fun `register returns PersistenceFailed when SessionTokensStore_save throws`() = runTest {
stubHappyPath()
@ -209,7 +233,7 @@ class DefaultDeviceRegistrarTest {
)
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64)
coEvery { authApi.register(any<RegisterApiRequest>()) } returns ApiResponse.Success(
coEvery { authApi.registerDevice(any<RegisterApiRequest>()) } returns ApiResponse.Success(
data = TokenApiResponse(
accessToken = "fresh-access",
accessTokenExpiresAt = "2024-01-01T00:00:00Z",

View file

@ -102,6 +102,7 @@ class DefaultSessionTokenRefresherTest {
assertThat(tokens.refreshToken).isEqualTo("rt-2")
assertThat(tokens.walletIds).containsExactly("w1", "w2")
coVerify { store.save(tokens) }
coVerify(exactly = 0) { authApi.authenticate(any()) }
}
@Test
@ -133,6 +134,34 @@ class DefaultSessionTokenRefresherTest {
coVerify { authApi.authenticate(any()) }
}
@Test
fun `refresh returns DeviceBlocked when refresh returns 403 — does not call authenticate`() = runTest {
val stored = SessionTokens(
accessToken = "old-access",
accessTokenExpiresAt = fixedClock.now().plus(60),
refreshToken = "rt-1",
refreshTokenExpiresAt = fixedClock.now().plus(3600),
walletIds = listOf("w1"),
)
coEvery { store.get() } returns Some(stored)
@Suppress("UNCHECKED_CAST")
coEvery { authApi.refresh(any()) } returns ApiResponse.Error(
cause = ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.FORBIDDEN,
message = "RED tier",
errorBody = null,
),
) as ApiResponse<TokenApiResponse>
val result = refresher.refresh()
// 403 means device is server-side blocked; /authenticate would also fail with 403.
// Don't fall through.
assertThat(result.leftOrNull()).isEqualTo(SessionRefreshError.DeviceBlocked)
coVerify(exactly = 0) { authApi.requestAuthNonce(any()) }
coVerify(exactly = 0) { authApi.authenticate(any()) }
}
@Test
fun `refresh clears store when authenticate returns 403`() = runTest {
coEvery { store.get() } returns None
@ -159,6 +188,17 @@ class DefaultSessionTokenRefresherTest {
coVerify { store.clear() }
}
@Test
fun `refresh returns DeviceKeyUnavailable when authenticate fallback has no key`() = runTest {
coEvery { store.get() } returns None
coEvery { deviceKeyManager.getPublicKey() } returns None
val result = refresher.refresh()
assertThat(result.leftOrNull()).isEqualTo(SessionRefreshError.DeviceKeyUnavailable)
coVerify(exactly = 0) { authApi.requestAuthNonce(any()) }
}
@Test
fun `concurrent callers share a single refresh round-trip — both get same result`() = runTest {
val stored = SessionTokens(

View file

@ -25,7 +25,7 @@ class SignedRequestPayloadTest {
private val signedRequestPayload = SignedRequestPayload(appInfoProvider)
@Test
fun `deviceMetadata wires AppInfoProvider fields, forces userAgent to null, lowercases platform`() {
fun `deviceMetadata wires AppInfoProvider fields, builds userAgent, lowercases platform`() {
val metadata = signedRequestPayload.deviceMetadata
// Backend contract is lowercase `android`/`ios` — verify normalization at the source.
@ -35,7 +35,7 @@ class SignedRequestPayloadTest {
os = "android",
osVersion = "14",
appVersion = "5.40.0",
userAgent = null,
userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)",
locale = "en-US",
timezone = "Europe/Moscow",
),
@ -49,7 +49,7 @@ class SignedRequestPayloadTest {
os = "Android",
osVersion = "14",
appVersion = "5.40.0",
userAgent = null,
userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)",
locale = "en-US",
timezone = "Europe/Moscow",
)
@ -71,6 +71,7 @@ class SignedRequestPayloadTest {
Android
14
5.40.0
Tangem/5.40.0 (Pixel 8; Android 14)
en-US
Europe/Moscow
""".trimIndent(),
@ -78,15 +79,15 @@ class SignedRequestPayloadTest {
}
@Test
fun `canonicalize replaces null fields with empty string`() {
fun `canonicalize replaces null attestationToken with empty string`() {
val metadata = DeviceMetadata(
deviceModel = null,
deviceModel = "Pixel 8",
os = "Android",
osVersion = null,
appVersion = null,
userAgent = null,
locale = null,
timezone = null,
osVersion = "14",
appVersion = "5.40.0",
userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)",
locale = "en-US",
timezone = "Europe/Moscow",
)
val payload = RegisterPayload(
devicePublicKey = "pub",
@ -97,8 +98,10 @@ class SignedRequestPayloadTest {
val bytes = signedRequestPayload.canonicalize(payload)
// 8 newlines separate 9 logical slots; all but `devicePublicKey`, `nonce`, and `os` are empty.
assertThat(bytes.toString(Charsets.UTF_8)).isEqualTo("pub\nnonce-1\n\n\nAndroid\n\n\n\n")
// The null attestationToken collapses to an empty slot between `nonce` and `deviceModel`.
assertThat(bytes.toString(Charsets.UTF_8)).isEqualTo(
"pub\nnonce-1\n\nPixel 8\nAndroid\n14\n5.40.0\nTangem/5.40.0 (Pixel 8; Android 14)\nen-US\nEurope/Moscow",
)
}
@Test
@ -110,7 +113,7 @@ class SignedRequestPayloadTest {
os = "Android",
osVersion = "14",
appVersion = "5.40.0",
userAgent = null,
userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)",
locale = "en-US",
timezone = "Europe/Moscow",
)

View file

@ -15,45 +15,53 @@ android {
dependencies {
// region Core modules
implementation(projects.core.datasource)
implementation(projects.core.configToggles)
implementation(projects.core.utils)
implementation(projects.core.analytics)
// endregion
api(projects.domain.models)
// region AndroidX libraries
implementation(deps.androidx.datastore)
// endregion
// region DI libraries
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Kotlin
api(deps.kotlin.coroutines)
// endregion
// region AndroidX
implementation(deps.androidx.core)
implementation(deps.androidx.datastore)
// endregion
// region Other libraries
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
// region Firebase libraries
// region Firebase
implementation(platform(deps.firebase.bom))
implementation(deps.firebase.analytics)
implementation(deps.firebase.crashlytics)
// endregion
// region Tangem libraries
implementation(tangemDeps.blockchain) { exclude(module = "joda-time") }
// region Tangem
api(tangemDeps.blockchain) { exclude(module = "joda-time") }
implementation(tangemDeps.card.core)
// endregion
// region Core modules
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
api(projects.core.configToggles)
api(projects.core.datasource)
implementation(projects.core.utils)
// endregion
// region Domain models
api(projects.domain.models)
// 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,176 @@
package com.tangem.blockchainsdk.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.models.network.Network
/**
* The kind of transaction extras (memo / destination tag) a [Blockchain] supports, mapped to the domain
* [Network.TransactionExtrasType]. Single source of truth for both [com.tangem.data.common.network.NetworkFactory] and
* any feature that needs to know whether an address on this chain can carry a memo/tag.
*/
@Suppress("LongMethod")
fun Blockchain.getSupportedTransactionExtras(): Network.TransactionExtrasType {
return when (this) {
Blockchain.XRP -> Network.TransactionExtrasType.DESTINATION_TAG
Blockchain.Binance,
Blockchain.TON,
Blockchain.Cosmos,
Blockchain.TerraV1,
Blockchain.TerraV2,
Blockchain.Stellar,
Blockchain.Hedera,
Blockchain.Algorand,
Blockchain.Sei,
Blockchain.InternetComputer,
Blockchain.Casper,
-> Network.TransactionExtrasType.MEMO
// region Other blockchains
Blockchain.Unknown,
Blockchain.Alephium,
Blockchain.AlephiumTestnet,
Blockchain.Arbitrum,
Blockchain.ArbitrumTestnet,
Blockchain.Avalanche,
Blockchain.AvalancheTestnet,
Blockchain.BinanceTestnet,
Blockchain.BSC,
Blockchain.BSCTestnet,
Blockchain.Bitcoin,
Blockchain.BitcoinTestnet,
Blockchain.BitcoinCash,
Blockchain.BitcoinCashTestnet,
Blockchain.Cardano,
Blockchain.CosmosTestnet,
Blockchain.Dogecoin,
Blockchain.Ducatus,
Blockchain.Ethereum,
Blockchain.EthereumTestnet,
Blockchain.EthereumClassic,
Blockchain.EthereumClassicTestnet,
Blockchain.Fantom,
Blockchain.FantomTestnet,
Blockchain.Gonka,
Blockchain.Litecoin,
Blockchain.Near,
Blockchain.NearTestnet,
Blockchain.Polkadot,
Blockchain.PolkadotTestnet,
Blockchain.Kava,
Blockchain.KavaTestnet,
Blockchain.Kusama,
Blockchain.Polygon,
Blockchain.PolygonTestnet,
Blockchain.RSK,
Blockchain.SeiTestnet,
Blockchain.StellarTestnet,
Blockchain.Solana,
Blockchain.SolanaTestnet,
Blockchain.Tezos,
Blockchain.Tron,
Blockchain.TronTestnet,
Blockchain.Gnosis,
Blockchain.Dash,
Blockchain.Optimism,
Blockchain.OptimismTestnet,
Blockchain.Dischain,
Blockchain.EthereumPow,
Blockchain.EthereumPowTestnet,
Blockchain.Kaspa,
Blockchain.KaspaTestnet,
Blockchain.Telos,
Blockchain.TelosTestnet,
Blockchain.TONTestnet,
Blockchain.Ravencoin,
Blockchain.Clore,
Blockchain.RavencoinTestnet,
Blockchain.Cronos,
Blockchain.AlephZero,
Blockchain.AlephZeroTestnet,
Blockchain.OctaSpace,
Blockchain.OctaSpaceTestnet,
Blockchain.Chia,
Blockchain.ChiaTestnet,
Blockchain.Decimal,
Blockchain.DecimalTestnet,
Blockchain.XDC,
Blockchain.XDCTestnet,
Blockchain.VeChain,
Blockchain.VeChainTestnet,
Blockchain.Aptos,
Blockchain.AptosTestnet,
Blockchain.Playa3ull,
Blockchain.Shibarium,
Blockchain.ShibariumTestnet,
Blockchain.AlgorandTestnet,
Blockchain.HederaTestnet,
Blockchain.Aurora,
Blockchain.AuroraTestnet,
Blockchain.Areon,
Blockchain.AreonTestnet,
Blockchain.PulseChain,
Blockchain.PulseChainTestnet,
Blockchain.ZkSyncEra,
Blockchain.ZkSyncEraTestnet,
Blockchain.Nexa,
Blockchain.NexaTestnet,
Blockchain.Moonbeam,
Blockchain.MoonbeamTestnet,
Blockchain.Manta,
Blockchain.MantaTestnet,
Blockchain.PolygonZkEVM,
Blockchain.PolygonZkEVMTestnet,
Blockchain.Radiant,
Blockchain.Fact0rn,
Blockchain.Base,
Blockchain.BaseTestnet,
Blockchain.Moonriver,
Blockchain.MoonriverTestnet,
Blockchain.Mantle,
Blockchain.MantleTestnet,
Blockchain.Flare,
Blockchain.FlareTestnet,
Blockchain.Taraxa,
Blockchain.TaraxaTestnet,
Blockchain.Koinos,
Blockchain.KoinosTestnet,
Blockchain.Joystream,
Blockchain.Bittensor,
Blockchain.Filecoin,
Blockchain.Blast,
Blockchain.BlastTestnet,
Blockchain.Cyber,
Blockchain.CyberTestnet,
Blockchain.Sui,
Blockchain.SuiTestnet,
Blockchain.EnergyWebChain,
Blockchain.EnergyWebChainTestnet,
Blockchain.EnergyWebX,
Blockchain.EnergyWebXTestnet,
Blockchain.CasperTestnet,
Blockchain.Core,
Blockchain.CoreTestnet,
Blockchain.Xodex,
Blockchain.Canxium,
Blockchain.Chiliz,
Blockchain.ChilizTestnet,
Blockchain.VanarChain,
Blockchain.VanarChainTestnet,
Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet,
Blockchain.Bitrock, Blockchain.BitrockTestnet,
Blockchain.Sonic, Blockchain.SonicTestnet,
Blockchain.ApeChain, Blockchain.ApeChainTestnet,
Blockchain.Scroll, Blockchain.ScrollTestnet,
Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet,
Blockchain.Pepecoin, Blockchain.PepecoinTestnet,
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet,
Blockchain.Quai, Blockchain.QuaiTestnet,
Blockchain.Linea, Blockchain.LineaTestnet,
Blockchain.ArbitrumNova,
Blockchain.Plasma, Blockchain.PlasmaTestnet,
Blockchain.Adi, Blockchain.AdiTestnet,
Blockchain.SeiEvm, Blockchain.SeiEvmTestnet,
Blockchain.Monad, Blockchain.MonadTestnet,
-> Network.TransactionExtrasType.NONE
// endregion
}
}

View file

@ -1,28 +1,24 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
android {
namespace = "com.tangem.lib.crypto"
namespace = "com.tangem.libs.crypto"
}
dependencies {
// region Tangem SDKs
api(tangemDeps.blockchain)
api(tangemDeps.card.core)
// endregion
// region Project
implementation(projects.core.utils)
implementation(projects.libs.blockchainSdk)
// endregion
// region Tangem SDKs
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain)
// endregion
// region Other deps
implementation(deps.kotlin.coroutines)
api(projects.domain.models)
api(projects.libs.blockchainSdk)
// endregion
// region Test libraries

View file

@ -0,0 +1,31 @@
package com.tangem.lib.crypto.derivation
import com.tangem.common.card.EllipticCurve
import com.tangem.crypto.hdWallet.DerivationPath
/**
* Checks whether this [EllipticCurve] is able to derive the given [path].
*
* `ed25519` and `ed25519_slip0010` support hardened derivation only (SLIP-0010), so any path that contains a
* non-hardened node cannot produce a key/address for them. This is the root cause of the "custom token added without
* an address" bug: e.g. an Algorand (ed25519) token with an EVM derivation path like `m/44'/60'/0'/0/0`.
*
* `secp256k1`, `secp256r1` and `bip0340` support both hardened and non-hardened derivation, so any path is fine.
*
* BLS curves do not support derivation at all.
*/
fun EllipticCurve.supportsDerivationPath(path: DerivationPath): Boolean {
return when (this) {
EllipticCurve.Ed25519,
EllipticCurve.Ed25519Slip0010,
-> path.nodes.all { it.isHardened }
EllipticCurve.Secp256k1,
EllipticCurve.Secp256r1,
EllipticCurve.Bip0340,
-> true
EllipticCurve.Bls12381G2,
EllipticCurve.Bls12381G2Aug,
EllipticCurve.Bls12381G2Pop,
-> false
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.lib.crypto.derivation
import com.google.common.truth.Truth
import com.tangem.common.card.EllipticCurve
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class EllipticCurveDerivationSupportTest {
@ParameterizedTest
@ProvideTestModels
fun supportsDerivationPath(model: TestModel) {
// Arrange
val path = DerivationPath(rawPath = model.derivationPath)
// Act
val actual = model.curve.supportsDerivationPath(path)
// Assert
Truth.assertThat(actual).isEqualTo(model.expected)
}
private fun provideTestModels() = provideEd25519Models() +
provideSecpModels() +
provideBlsModels()
private fun provideEd25519Models() = listOf(
// region ed25519-family require a fully-hardened path
// Algorand default path — fully hardened
TestModel(curve = EllipticCurve.Ed25519, derivationPath = "m/44'/283'/0'/0'/0'", expected = true),
TestModel(curve = EllipticCurve.Ed25519Slip0010, derivationPath = "m/44'/283'/0'/0'/0'", expected = true),
// The reported bug: Algorand (ed25519) + ApeChain/EVM path with non-hardened tail
TestModel(curve = EllipticCurve.Ed25519, derivationPath = "m/44'/60'/0'/0/0", expected = false),
TestModel(curve = EllipticCurve.Ed25519Slip0010, derivationPath = "m/44'/60'/0'/0/0", expected = false),
// Solana default path — fully hardened
TestModel(curve = EllipticCurve.Ed25519Slip0010, derivationPath = "m/44'/501'/0'/0'", expected = true),
// A single non-hardened node is enough to make it unsupported
TestModel(curve = EllipticCurve.Ed25519, derivationPath = "m/44'/283'/0'/0'/0", expected = false),
// endregion
)
private fun provideSecpModels() = listOf(
// region secp256k1 / secp256r1 / bip0340 accept any path
TestModel(curve = EllipticCurve.Secp256k1, derivationPath = "m/44'/60'/0'/0/0", expected = true),
TestModel(curve = EllipticCurve.Secp256k1, derivationPath = "m/44'/0'/0'/0/0", expected = true),
TestModel(curve = EllipticCurve.Secp256k1, derivationPath = "m/44'/283'/0'/0'/0'", expected = true),
TestModel(curve = EllipticCurve.Secp256r1, derivationPath = "m/44'/60'/0'/0/0", expected = true),
TestModel(curve = EllipticCurve.Bip0340, derivationPath = "m/44'/60'/0'/0/0", expected = true),
// endregion
)
private fun provideBlsModels() = listOf(
// region BLS curves do not support derivation at all
TestModel(curve = EllipticCurve.Bls12381G2, derivationPath = "m/44'/60'/0'/0/0", expected = false),
TestModel(curve = EllipticCurve.Bls12381G2Aug, derivationPath = "m/44'/60'/0'/0'/0'", expected = false),
TestModel(curve = EllipticCurve.Bls12381G2Pop, derivationPath = "m/44'/60'/0'/0'/0'", expected = false),
// endregion
)
data class TestModel(
val curve: EllipticCurve,
val derivationPath: String,
val expected: Boolean,
)
}

View file

@ -7,24 +7,39 @@ plugins {
}
android {
namespace = "com.tangem.legacy"
namespace = "com.tangem.libs.tangem_sdk_api"
}
dependencies {
implementation(projects.domain.models)
implementation(projects.domain.visa.models)
api(projects.core.analytics.models)
implementation(projects.core.configToggles)
implementation(projects.core.res)
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/** Tangem libraries */
implementation(tangemDeps.card.core)
// region AndroidX
api(deps.androidx.activity)
api(deps.androidx.annotation)
// endregion
// region Other libraries
api(deps.arrow.core)
// endregion
// region Tangem
api(tangemDeps.card.core)
implementation(tangemDeps.card.android) {
exclude(module = "joda-time")
}
// endregion
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// region Core modules
api(projects.core.analytics.models)
implementation(projects.core.configToggles)
// endregion
// region Domain models
api(projects.domain.models)
api(projects.domain.visa.models)
// endregion
}

View file

@ -1,13 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:TangemSdkManager.kt$TangemSdkManager$val needEnrollBiometrics: Boolean</ID>
<ID>ObjectExtendsThrowable:TapErrors.kt$TapError$NoInternetConnection : TapError</ID>
<ID>ObjectExtendsThrowable:TapErrors.kt$TapError$UnknownError : TapError</ID>
<ID>ObjectExtendsThrowable:TapErrors.kt$TapError.WalletManager$BlockchainIsUnreachableTryLater : TapError</ID>
<ID>ObjectExtendsThrowable:TapErrors.kt$TapSdkError$CardForDifferentApp : TapSdkError</ID>
<ID>ObjectExtendsThrowable:TapErrors.kt$TapSdkError$CardNotSupportedByRelease : TapSdkError</ID>
<ID>UseEmptyCounterpart:CreateProductWalletTaskResponse.kt$CreateProductWalletTaskResponse$mapOf()</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -9,12 +9,12 @@ import com.tangem.operations.derivation.ExtendedPublicKeysMap
data class CreateProductWalletTaskResponse(
val card: CardDTO,
val derivedKeys: Map<KeyWalletPublicKey, ExtendedPublicKeysMap> = mapOf(),
val derivedKeys: Map<KeyWalletPublicKey, ExtendedPublicKeysMap> = emptyMap(),
val primaryCard: PrimaryCard? = null,
) : CommandResponse {
constructor(
card: Card,
derivedKeys: Map<KeyWalletPublicKey, ExtendedPublicKeysMap> = mapOf(),
derivedKeys: Map<KeyWalletPublicKey, ExtendedPublicKeysMap> = emptyMap(),
primaryCard: PrimaryCard? = null,
) : this(
card = CardDTO(card),

View file

@ -20,6 +20,7 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalSignatureResult
import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VirtualAccountActivationData
import com.tangem.domain.visa.model.VisaActivationInput
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
@ -34,7 +35,7 @@ interface TangemSdkManager {
val canUseBiometry: Boolean
val needEnrollBiometrics: Boolean
val isEnrollBiometricsNeeded: Boolean
val keystoreManager: KeystoreManager
@ -175,6 +176,10 @@ interface TangemSdkManager {
preflightReadFilter: PreflightReadFilter,
): Either<Throwable, TangemPayInitialCredentials>
suspend fun tangemPayProduceVirtualAccountData(
preflightReadFilter: PreflightReadFilter,
): Either<Throwable, VirtualAccountActivationData>
suspend fun getWithdrawalSignature(
hash: String,
preflightReadFilter: PreflightReadFilter,

View file

@ -1,49 +0,0 @@
package com.tangem.sdk.api
import androidx.annotation.StringRes
import com.tangem.common.core.TangemError
import com.tangem.legacy.R
interface TapErrors
interface ArgError {
val args: List<Any>?
}
interface MultiMessageError : TapErrors {
val errorList: List<TapError>
val builder: (List<String>) -> String
}
sealed class TapError(
@StringRes val messageResource: Int,
override val args: List<Any>? = null,
) : Throwable(), TapErrors, ArgError {
object UnknownError : TapError(R.string.send_error_unknown)
open class CustomError(val customMessage: String) : TapError(R.string.common_custom_string, listOf(customMessage))
object NoInternetConnection : TapError(R.string.wallet_notification_no_internet)
sealed class WalletManager {
class NoAccountError(amountToCreateAccount: String) : CustomError(amountToCreateAccount)
class InternalError(message: String) : CustomError(message)
object BlockchainIsUnreachableTryLater : TapError(R.string.wallet_balance_blockchain_unreachable_try_later)
}
}
sealed class TapSdkError(override val messageResId: Int?) : TangemError(code = 50100) {
override var customMessage: String = code.toString()
object CardForDifferentApp : TapSdkError(R.string.alert_unsupported_card)
object CardNotSupportedByRelease : TapSdkError(R.string.error_wrong_card_type)
}
fun TapErrors.assembleErrors(): MutableList<Pair<Int, List<Any>?>> {
val idList = mutableListOf<Pair<Int, List<Any>?>>()
when (this) {
is MultiMessageError -> this.errorList.forEach { idList.addAll(it.assembleErrors()) }
is TapError -> idList.add(Pair(this.messageResource, this.args))
}
return idList
}

View file

@ -1,10 +1,6 @@
import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.ksp)
id("configuration")
}
@ -20,23 +16,19 @@ android {
dependencies {
/** Project */
implementation(projects.core.utils)
implementation(projects.core.datasource)
implementation(projects.data.common)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
/** Libs - Network */
implementation(deps.moshi.kotlin)
// region Other libraries
implementation(deps.arrow.fx)
api(deps.jodatime)
implementation(deps.okHttp)
implementation(deps.okHttp.prettyLogging)
implementation(deps.retrofit)
implementation(deps.retrofit.moshi)
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
/** Libs - Other */
implementation(deps.web3j.core)
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.fx)
implementation(deps.jodatime)
// endregion
// region Core modules
api(projects.core.utils)
// endregion
}

View file

@ -1,11 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:VisaContractInfoProvider.kt$VisaContractInfoProvider.Builder$private val useTestnetRpc: Boolean</ID>
<ID>NamedArguments:DefaultVisaContractInfoProvider.kt$DefaultVisaContractInfoProvider$parZip( dispatchers.io, { fetchToken(paymentAccount) }, { fetchBalances(paymentAccount, paymentToken) }, { fetchLimits(paymentAccount, paymentToken, walletAddress) }, { token, balances, (oldLimit, newLimit, changeDate) -&gt; VisaContractInfo( token = token, balances = balances, oldLimits = oldLimit, newLimits = newLimit, paymentAccountAddress = paymentAccount.contractAddress, limitsChangeDate = changeDate, ) }, )</ID>
<ID>NamedArguments:DefaultVisaContractInfoProvider.kt$DefaultVisaContractInfoProvider$parZip( dispatchers.io, { loadPaymentAccount(walletAddress = walletAddress, paymentAccountAddress = paymentAccountAddress) }, { loadPaymentTokenInfo() }, { paymentAccount, paymentToken -&gt; fetchBalancesAndLimits( paymentAccount = paymentAccount, paymentToken = paymentToken, walletAddress = walletAddress, ) }, )</ID>
<ID>NamedArguments:DefaultVisaContractInfoProvider.kt$DefaultVisaContractInfoProvider$parZip( dispatchers.io, { paymentToken.contract.balanceOf(paymentAccount.contractAddress).send() }, { paymentAccount.verifiedBalance().send() }, { paymentAccount.availableForPayment().send() }, { paymentAccount.availableForWithdrawal().send() }, { paymentAccount.availableForDebtPayment().send() }, { paymentAccount.blockedAmount().send() }, { paymentAccount.debtAmount().send() }, ) { total, verified, payment, withdrawal, debtPayment, blocked, debt -&gt; val decimals = paymentToken.decimals Balances( total = total.toBigDecimal(decimals), verified = verified.toBigDecimal(decimals), available = Balances.Available( forPayment = payment.toBigDecimal(decimals), forWithdrawal = withdrawal.toBigDecimal(decimals), forDebtPayment = debtPayment.toBigDecimal(decimals), ), blocked = blocked.toBigDecimal(decimals), debt = debt.toBigDecimal(decimals), ) }</ID>
<ID>NamedArguments:DefaultVisaContractInfoProvider.kt$DefaultVisaContractInfoProvider$parZip( dispatchers.io, { paymentTokenContract.name().send() }, { paymentTokenContract.symbol().send() }, { paymentTokenContract.decimals().send() }, ) { name, symbol, decimals -&gt; Token( name = name, symbol = symbol, decimals = decimals.toInt(), address = paymentTokenContractAddress, ) }</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -20,6 +20,10 @@ internal class DefaultVisaContractInfoProvider(
private val dispatchers: CoroutineDispatcherProvider,
) : VisaContractInfoProvider {
// NamedArguments flags the parZip(...) invocation itself (a dispatcher + several positional
// supplier lambdas + a result combiner); those positional lambda parameters can't be meaningfully
// named, so it is suppressed here. Calls inside the lambdas still use named arguments.
@Suppress("NamedArguments")
override suspend fun getContractInfo(walletAddress: String, paymentAccountAddress: String?): VisaContractInfo {
return parZip(
dispatchers.io,
@ -71,6 +75,7 @@ internal class DefaultVisaContractInfoProvider(
)
}
@Suppress("NamedArguments") // parZip(...) call: positional supplier/combiner lambdas, not meaningfully nameable
private suspend fun fetchBalancesAndLimits(
paymentAccount: TangemPaymentAccount,
paymentToken: PaymentTokenInfo,
@ -92,6 +97,7 @@ internal class DefaultVisaContractInfoProvider(
},
)
@Suppress("NamedArguments") // parZip(...) call: positional supplier/combiner lambdas, not meaningfully nameable
private suspend fun fetchToken(paymentAccount: TangemPaymentAccount): Token {
val paymentTokenContractAddress = paymentAccount.paymentToken().send()
val paymentTokenContract = ERC20.load(paymentTokenContractAddress, web3j, transactionManager, gasProvider)
@ -111,6 +117,7 @@ internal class DefaultVisaContractInfoProvider(
}
}
@Suppress("NamedArguments") // parZip(...) call: positional supplier/combiner lambdas, not meaningfully nameable
private suspend fun fetchBalances(paymentAccount: TangemPaymentAccount, paymentToken: PaymentTokenInfo): Balances {
return parZip(
dispatchers.io,

View file

@ -31,7 +31,7 @@ interface VisaContractInfoProvider {
suspend fun getContractInfo(walletAddress: String, paymentAccountAddress: String?): VisaContractInfo
class Builder(
private val useTestnetRpc: Boolean,
private val isTestnetRpcEnabled: Boolean,
private val bridgeProcessorAddress: String,
private val paymentAccountRegistryAddress: String,
private val isNetworkLoggingEnabled: Boolean,
@ -59,7 +59,7 @@ interface VisaContractInfoProvider {
}
private fun createWeb3J(): Web3j {
val baseUrl: String = if (useTestnetRpc) Constants.TESTNET_RPC_URL else Constants.MAINNET_RPC_URL
val baseUrl: String = if (isTestnetRpcEnabled) Constants.TESTNET_RPC_URL else Constants.MAINNET_RPC_URL
val httpClient = OkHttpClient.Builder().apply {
connectTimeout(networkTimeoutSeconds, TimeUnit.SECONDS)