Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-20 12:47:25 +03:00
commit c50840894f
739 changed files with 27664 additions and 4701 deletions

View file

@ -85,6 +85,18 @@ interface TangemPayApi {
@Body body: FreezeUnfreezeCardRequest,
): ApiResponse<FreezeUnfreezeCardResponse>
@GET("v1/fees/{type}")
suspend fun getFee(
@Header("Authorization") authHeader: String,
@Path("type") type: String,
): ApiResponse<FeeResponse>
@POST("v1/customer/card/reissue")
suspend fun reissueCard(
@Header("Authorization") authHeader: String,
@Body body: ReissueCardRequest,
): ApiResponse<ReissueCardResponse>
@POST("v1/customer/card/withdraw/data")
suspend fun getWithdrawData(
@Header("Authorization") authHeader: String,
@ -96,4 +108,10 @@ interface TangemPayApi {
@Header("Authorization") authHeader: String,
@Body body: WithdrawRequest,
): ApiResponse<WithdrawResponse>
@PATCH("v1/card")
suspend fun updateCardDisplayName(
@Header("Authorization") authHeader: String,
@Body body: UpdateCardDisplayNameRequest,
): ApiResponse<UpdateCardDisplayNameResponse>
}

View file

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

View file

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

View file

@ -29,6 +29,7 @@ data class CustomerMeResponse(
@Json(name = "status") val status: Status,
@Json(name = "updated_at") val updatedAt: String,
@Json(name = "payment_account_id") val paymentAccountId: String,
@Json(name = "display_name") val displayName: String?,
) {
@JsonClass(generateAdapter = false)
enum class Status {

View file

@ -0,0 +1,17 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class FeeResponse(
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "type") val type: String,
@Json(name = "amount") val amount: String,
@Json(name = "currency") val currency: String,
@Json(name = "description") val description: String,
)
}

View file

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

View file

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

View file

@ -42,7 +42,8 @@ data class UserTokensResponse(
return otherToken.contractAddress == this.contractAddress &&
otherToken.networkId == this.networkId &&
otherToken.derivationPath == this.derivationPath &&
otherToken.decimals == this.decimals
otherToken.decimals == this.decimals &&
otherToken.dynamicAddressesEnabled == this.dynamicAddressesEnabled
}
override fun hashCode(): Int = calculateHashCode(
@ -50,6 +51,7 @@ data class UserTokensResponse(
networkId.hashCode(),
derivationPath.hashCode(),
decimals.hashCode(),
dynamicAddressesEnabled.hashCode(),
)
}

View file

@ -2,7 +2,9 @@ package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayReissueCardStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -20,4 +22,12 @@ internal object TangemPayStoresModule {
dataStore = RuntimeDataStore(),
)
}
@Provides
@Singleton
fun provideTangemPayReissueCardStore(): TangemPayReissueCardStore {
return DefaultTangemPayReissueCardStore(
feeStore = RuntimeDataStore(),
)
}
}

View file

@ -29,11 +29,12 @@ sealed interface NetworkStatusDM {
/**
* Verified
*
* @property networkId network id
* @property derivationPath derivation path
* @property selectedAddress selected address
* @property availableAddresses available addresses
* @property amounts amounts
* @property networkId network id
* @property derivationPath derivation path
* @property selectedAddress selected address
* @property availableAddresses available addresses
* @property amounts amounts
* @property yieldSupplyStatuses yield supply statuses
*/
@NameLabel("amounts")
data class Verified(
@ -65,6 +66,11 @@ sealed interface NetworkStatusDM {
@Json(name = "error_message") val errorMessage: String,
) : NetworkStatusDM
/**
* Id
*
* @property value blockchain id [com.tangem.blockchain.common.Blockchain.id]
*/
@JsonClass(generateAdapter = true)
data class ID(
@Json(name = "value") val value: String,

View file

@ -16,6 +16,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.IS_WALLET_SWAP_PR
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SHOW_RING_PROMO_KEY
import com.tangem.datasource.local.preferences.utils.CleanupKeyMigration
import com.tangem.datasource.local.preferences.utils.SharedPreferencesKeyMigration
import com.tangem.datasource.local.preferences.utils.SwapCurrencyIdMigration
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
@ -80,6 +81,7 @@ internal object PreferencesDataStore {
legacyKeyName = LEGACY_DEFAULT_KEY_NAME,
keyName = PreferencesKeys.BALANCE_HIDING_SETTINGS_KEY.name,
),
SwapCurrencyIdMigration(),
CleanupKeyMigration(key = APP_LOGS_KEY),
CleanupKeyMigration(key = IS_WALLET_SWAP_PROMO_OKX_SHOW_KEY),
CleanupKeyMigration(key = IS_TOKEN_SWAP_PROMO_OKX_SHOW_KEY),

View file

@ -136,7 +136,7 @@ object PreferencesKeys {
val HAS_HAD_FIRST_TOP_UP_KEY by lazy { stringPreferencesKey(name = "hasHadFirstTopUp") }
val PENDING_DISCOVERY_SYNC_KEY by lazy { stringPreferencesKey(name = "pendingDiscoverySync") }
val PENDING_ASSETS_DISCOVERY_KEY by lazy { stringPreferencesKey(name = "pendingAssetsDiscovery") }
// region Notifications
val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") }

View file

@ -0,0 +1,151 @@
package com.tangem.datasource.local.preferences.utils
import androidx.datastore.core.DataMigration
import androidx.datastore.preferences.core.Preferences
import com.tangem.datasource.local.preferences.PreferencesKeys
/**
* Migrates cached CryptoCurrency.ID strings from old blockchain.id format to new networkId format.
*
* After refactoring, Network.rawId stores backendId values (e.g. "ethereum") instead of
* blockchain.id values (e.g. "ETH"). CryptoCurrency.ID body contains this value, so cached IDs
* like "coin⟨ETH⟩ethereum" must become "coin⟨ethereum⟩ethereum".
*
* Affected DataStore keys: [PreferencesKeys.SWAP_TRANSACTIONS_KEY],
* [PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY].
*/
internal class SwapCurrencyIdMigration : DataMigration<Preferences> {
override suspend fun shouldMigrate(currentData: Preferences): Boolean {
return currentData.contains(PreferencesKeys.SWAP_TRANSACTIONS_KEY) ||
currentData.contains(PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY)
}
override suspend fun migrate(currentData: Preferences): Preferences {
val mutablePrefs = currentData.toMutablePreferences()
currentData[PreferencesKeys.SWAP_TRANSACTIONS_KEY]?.let { json ->
mutablePrefs[PreferencesKeys.SWAP_TRANSACTIONS_KEY] = migrateJson(json)
}
currentData[PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY]?.let { json ->
mutablePrefs[PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY] = migrateJson(json)
}
return mutablePrefs.toPreferences()
}
override suspend fun cleanUp() {
// nothing to clean up
}
/**
* Replaces old blockchain.id values with networkId values inside CryptoCurrency.ID strings
* found anywhere in the JSON. Works by finding all `oldId` and `oldId` patterns and
* replacing the old ID with the new one.
*/
private fun migrateJson(json: String): String {
var result = json
for ((oldId, newId) in BLOCKCHAIN_ID_TO_NETWORK_ID) {
// Body without derivation path: ⟨oldId⟩ → ⟨newId⟩
result = result.replace("$BODY_START$oldId$BODY_END", "$BODY_START$newId$BODY_END")
// Body with derivation path: ⟨oldId→ → ⟨newId→
result = result.replace(
"$BODY_START$oldId$DERIVATION_DELIMITER",
"$BODY_START$newId$DERIVATION_DELIMITER",
)
}
return result
}
private companion object {
const val BODY_START = '\u27E8' // ⟨
const val BODY_END = '\u27E9' // ⟩
const val DERIVATION_DELIMITER = '\u2192' // →
/** Mapping of old blockchain.id → new networkId (only entries where values differ). */
val BLOCKCHAIN_ID_TO_NETWORK_ID = mapOf(
"ARBITRUM-ONE" to "arbitrum-one",
"ARBITRUM/test" to "arbitrum-one/test",
"AVALANCHE" to "avalanche",
"AVALANCHE/test" to "avalanche/test",
"BINANCE" to "binancecoin",
"BINANCE/test" to "binancecoin/test",
"BSC" to "binance-smart-chain",
"BSC/test" to "binance-smart-chain/test",
"BTC" to "bitcoin",
"BTC/test" to "bitcoin/test",
"BCH" to "bitcoin-cash",
"BCH/test" to "bitcoin-cash/test",
"CARDANO-S" to "cardano",
"DOGE" to "dogecoin",
"DUC" to "ducatus",
"ETH" to "ethereum",
"ETH/test" to "ethereum/test",
"ETC" to "ethereum-classic",
"ETC/test" to "ethereum-classic/test",
"ETH-Pow" to "ethereum-pow-iou",
"ETH-Pow/test" to "ethereum-pow-iou/test",
"FTM" to "fantom",
"FTM/test" to "fantom/test",
"GNO" to "xdai",
"KAS" to "kaspa",
"KAS/test" to "kaspa/test",
"KAVA" to "kava",
"KAVA/test" to "kava/test",
"Kusama" to "kusama",
"LTC" to "litecoin",
"NEAR" to "near-protocol",
"NEAR/test" to "near-protocol/test",
"NEXA" to "nexa",
"NEXA/test" to "nexa/test",
"OPTIMISM" to "optimistic-ethereum",
"Polkadot" to "polkadot",
"POLYGON" to "polygon-pos",
"POLYGON/test" to "polygon-pos/test",
"RSK" to "rootstock",
"SOLANA" to "solana",
"SOLANA/test" to "solana/test",
"TELOS" to "telos",
"TELOS/test" to "telos/test",
"The-Open-Network" to "the-open-network",
"The-Open-Network/test" to "the-open-network/test",
"TRON" to "tron",
"TRON/test" to "tron/test",
"XLM" to "stellar",
"XLM/test" to "stellar/test",
"XRP" to "xrp",
"XTZ" to "tezos",
"DASH" to "dash",
"xdc" to "xdc-network",
"xdc/test" to "xdc-network/test",
"hedera" to "hedera-hashgraph",
"hedera/test" to "hedera-hashgraph/test",
"areon" to "areon-network",
"areon/test" to "areon-network/test",
"pls" to "pulsechain",
"pls/test" to "pulsechain/test",
"zkSyncEra" to "zksync",
"zkSyncEra/test" to "zksync/test",
"polygonZkEVM" to "polygon-zkevm",
"polygonZkEVM/test" to "polygon-zkevm/test",
"flare" to "flare-network",
"flare/test" to "flare-network/test",
"playa3ull" to "playa3ull-games",
"sei" to "sei-network",
"sei/test" to "sei-network/test",
"casper" to "casper-network",
"casper/test" to "casper-network/test",
"odyssey" to "dione",
"odyssey/test" to "dione/test",
"hyperliquid" to "hyperevm",
"hyperliquid/test" to "hyperevm/test",
"quai" to "quai-network",
"quai/test" to "quai-network/test",
"manta/test" to "manta-pacific/test",
"dischain" to "ethereumfair",
)
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.datasource.local.visa
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.domain.models.TangemPayReissueCardFee
import com.tangem.domain.models.wallet.UserWalletId
internal class DefaultTangemPayReissueCardStore(
private val feeStore: RuntimeDataStore<TangemPayReissueCardFee>,
) : TangemPayReissueCardStore {
override suspend fun storeReissueFee(
userWalletId: UserWalletId,
tangemPayReissueCardFee: TangemPayReissueCardFee,
) {
feeStore.store(userWalletId.stringValue, tangemPayReissueCardFee)
}
override suspend fun getReissueFee(userWalletId: UserWalletId): TangemPayReissueCardFee? {
return feeStore.getSyncOrNull(userWalletId.stringValue)
}
override suspend fun storeReissueOrderId(cardId: String, orderId: String) {
// TODO v_rodionov: #[REDACTED_TASK_KEY] store orderId in app prefs
}
override suspend fun getOrderId(cardId: String): String? {
// TODO v_rodionov: #[REDACTED_TASK_KEY] store orderId in app prefs
return null
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.models.TangemPayReissueCardFee
import com.tangem.domain.models.wallet.UserWalletId
interface TangemPayReissueCardStore {
suspend fun storeReissueFee(userWalletId: UserWalletId, tangemPayReissueCardFee: TangemPayReissueCardFee)
suspend fun getReissueFee(userWalletId: UserWalletId): TangemPayReissueCardFee?
suspend fun storeReissueOrderId(cardId: String, orderId: String)
suspend fun getOrderId(cardId: String): String?
}

View file

@ -43,6 +43,7 @@ sealed interface PaymentAccountStatusValueDM {
@Json(name = "is_pin_set") val isPinSet: Boolean,
@Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM,
@Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM,
@Json(name = "display_name") val displayName: String?,
) : PaymentAccountStatusValueDM
@NameLabel("card_issue_failed")

View file

@ -0,0 +1,224 @@
package com.tangem.datasource.local.preferences.utils
import androidx.datastore.preferences.core.mutablePreferencesOf
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.preferences.PreferencesKeys
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class SwapCurrencyIdMigrationTest {
private val migration = SwapCurrencyIdMigration()
// region shouldMigrate
@Test
fun `shouldMigrate returns true when swap transactions key exists`() = runTest {
val prefs = mutablePreferencesOf(PreferencesKeys.SWAP_TRANSACTIONS_KEY to "[]")
assertThat(migration.shouldMigrate(prefs)).isTrue()
}
@Test
fun `shouldMigrate returns true when last swapped currency key exists`() = runTest {
val prefs = mutablePreferencesOf(PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY to "[]")
assertThat(migration.shouldMigrate(prefs)).isTrue()
}
@Test
fun `shouldMigrate returns true when both keys exist`() = runTest {
val prefs = mutablePreferencesOf(
PreferencesKeys.SWAP_TRANSACTIONS_KEY to "[]",
PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY to "[]",
)
assertThat(migration.shouldMigrate(prefs)).isTrue()
}
@Test
fun `shouldMigrate returns false when no keys exist`() = runTest {
val prefs = mutablePreferencesOf()
assertThat(migration.shouldMigrate(prefs)).isFalse()
}
// endregion
// region migrate — coin IDs without derivation path
@Test
fun `migrates simple coin ID - ETH to ethereum`() = runTest {
val result = migrateLastSwapped(currencyIdJson("coin${BS}ETH${BE}ethereum"))
assertThat(result).isEqualTo(currencyIdJson("coin${BS}ethereum${BE}ethereum"))
}
@Test
fun `migrates simple coin ID - BTC to bitcoin`() = runTest {
val result = migrateLastSwapped(currencyIdJson("coin${BS}BTC${BE}bitcoin"))
assertThat(result).isEqualTo(currencyIdJson("coin${BS}bitcoin${BE}bitcoin"))
}
@Test
fun `migrates BSC to binance-smart-chain`() = runTest {
val result = migrateLastSwapped(currencyIdJson("coin${BS}BSC${BE}binancecoin"))
assertThat(result).isEqualTo(currencyIdJson("coin${BS}binance-smart-chain${BE}binancecoin"))
}
// endregion
// region migrate — coin IDs with derivation path
@Test
fun `migrates coin ID with derivation path`() = runTest {
val result = migrateLastSwapped(currencyIdJson("coin${BS}ETH${DP}12367123${BE}ethereum"))
assertThat(result).isEqualTo(currencyIdJson("coin${BS}ethereum${DP}12367123${BE}ethereum"))
}
@Test
fun `migrates POLYGON with derivation path`() = runTest {
val result = migrateLastSwapped(currencyIdJson("coin${BS}POLYGON${DP}99999${BE}polygon-pos"))
assertThat(result).isEqualTo(currencyIdJson("coin${BS}polygon-pos${DP}99999${BE}polygon-pos"))
}
// endregion
// region migrate — token IDs
@Test
fun `migrates token ID with contract address`() = runTest {
val result = migrateLastSwapped(currencyIdJson("token${BS}ETH${BE}usdt${CA}0xdAC17"))
assertThat(result).isEqualTo(currencyIdJson("token${BS}ethereum${BE}usdt${CA}0xdAC17"))
}
@Test
fun `migrates token ID with derivation path and contract address`() = runTest {
val result = migrateLastSwapped(
currencyIdJson("token${BS}ETH${DP}12345${BE}usdt${CA}0xdAC17"),
)
assertThat(result).isEqualTo(
currencyIdJson("token${BS}ethereum${DP}12345${BE}usdt${CA}0xdAC17"),
)
}
// endregion
// region migrate — swap transactions (both from and to IDs)
@Test
fun `migrates both fromCryptoCurrencyId and toCryptoCurrencyId`() = runTest {
val from = "coin${BS}ETH${BE}ethereum"
val to = "coin${BS}BTC${BE}bitcoin"
val oldJson = "[{\"fromCryptoCurrencyId\":\"$from\",\"toCryptoCurrencyId\":\"$to\"}]"
val expectedFrom = "coin${BS}ethereum${BE}ethereum"
val expectedTo = "coin${BS}bitcoin${BE}bitcoin"
val expected = "[{\"fromCryptoCurrencyId\":\"$expectedFrom\",\"toCryptoCurrencyId\":\"$expectedTo\"}]"
val result = migrateSwapTransactions(oldJson)
assertThat(result).isEqualTo(expected)
}
// endregion
// region migrate — no-op cases
@Test
fun `does not modify already migrated IDs`() = runTest {
val json = currencyIdJson("coin${BS}ethereum${BE}ethereum")
val result = migrateLastSwapped(json)
assertThat(result).isEqualTo(json)
}
@Test
fun `does not modify IDs where blockchain id equals networkId`() = runTest {
val json = currencyIdJson("coin${BS}cosmos${BE}cosmos")
val result = migrateLastSwapped(json)
assertThat(result).isEqualTo(json)
}
@Test
fun `does not modify empty list`() = runTest {
val result = migrateLastSwapped("[]")
assertThat(result).isEqualTo("[]")
}
// endregion
// region migrate — multiple entries
@Test
fun `migrates multiple entries in list`() = runTest {
val old1 = currencyIdValue("coin${BS}ETH${BE}ethereum")
val old2 = currencyIdValue("coin${BS}TRON${BE}tron")
val oldJson = "[$old1,$old2]"
val new1 = currencyIdValue("coin${BS}ethereum${BE}ethereum")
val new2 = currencyIdValue("coin${BS}tron${BE}tron")
val expected = "[$new1,$new2]"
val result = migrateLastSwapped(oldJson)
assertThat(result).isEqualTo(expected)
}
// endregion
// region migrate — preserves unrelated keys
@Test
fun `preserves unrelated preference keys`() = runTest {
val unrelatedKey = PreferencesKeys.BALANCE_HIDING_SETTINGS_KEY
val unrelatedValue = "some_value"
val prefs = mutablePreferencesOf(
PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY to currencyIdJson("coin${BS}ETH${BE}ethereum"),
unrelatedKey to unrelatedValue,
)
val result = migration.migrate(prefs)
assertThat(result[unrelatedKey]).isEqualTo(unrelatedValue)
}
// endregion
// region helpers
private suspend fun migrateLastSwapped(json: String): String? {
val prefs = mutablePreferencesOf(PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY to json)
val result = migration.migrate(prefs)
return result[PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY]
}
private suspend fun migrateSwapTransactions(json: String): String? {
val prefs = mutablePreferencesOf(PreferencesKeys.SWAP_TRANSACTIONS_KEY to json)
val result = migration.migrate(prefs)
return result[PreferencesKeys.SWAP_TRANSACTIONS_KEY]
}
private fun currencyIdJson(id: String): String = "[${currencyIdValue(id)}]"
private fun currencyIdValue(id: String): String = "{\"cryptoCurrencyId\":\"$id\"}"
private companion object {
const val BS = '\u27E8' // ⟨ body start
const val BE = '\u27E9' // ⟩ body end
const val DP = '\u2192' // → derivation path delimiter
const val CA = '\u2693' // ⚓ contract address delimiter
}
// endregion
}