Updated on 2026-08-14
This commit is contained in:
commit
92849d43c2
1407 changed files with 64092 additions and 11990 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = """
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) -> 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 -> 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 -> 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 -> Token( name = name, symbol = symbol, decimals = decimals.toInt(), address = paymentTokenContractAddress, ) }</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue