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

@ -60,70 +60,80 @@ androidComponents {
}
dependencies {
/** Project */
implementation(projects.core.analytics)
implementation(projects.core.utils)
implementation(projects.core.res)
implementation(projects.domain.appTheme.models)
implementation(projects.domain.core)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.staking.models)
implementation(projects.domain.onramp.models)
implementation(projects.domain.models)
implementation(projects.domain.nft.models)
implementation(projects.domain.walletConnect.models)
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.visa.models)
/** Tangem libraries */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
/** DI */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/** Coroutines */
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.coroutines.rx2)
// region Kotlin
api(deps.kotlin.coroutines)
api(deps.kotlin.datetime)
api(deps.kotlin.serialization)
// endregion
/** Logging */
// region AndroidX
implementation(deps.androidx.core)
api(deps.androidx.datastore)
// endregion
/** Network */
// region Network
api(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.moshi.adapters)
implementation(deps.moshi.adapters.ext)
implementation(deps.moshi.kotlin)
ksp(deps.moshi.kotlin.codegen)
api(deps.okHttp)
implementation(deps.okHttp.prettyLogging)
implementation(deps.okio)
api(deps.retrofit)
implementation(deps.retrofit.moshi)
ksp(deps.moshi.kotlin.codegen)
api(deps.retrofit.moshi)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
/** Time */
implementation(deps.jodatime)
// region Room
api(deps.room.runtime)
api(deps.room.ktx)
ksp(deps.room.compiler)
// endregion
/** Security */
implementation(deps.spongecastle.core)
// region Other libraries
api(deps.jodatime)
runtimeOnly(deps.spongecastle.core)
// endregion
/** Chucker */
// region Chucker
debugImplementation(deps.chucker)
mockedImplementation(deps.chucker)
externalImplementation(deps.chuckerStub)
internalImplementation(deps.chuckerStub)
releaseImplementation(deps.chuckerStub)
// endregion
/** Local storages */
api(deps.androidx.datastore)
implementation(deps.room.runtime)
implementation(deps.room.ktx)
ksp(deps.room.compiler)
// region Tangem
api(tangemDeps.blockchain)
api(tangemDeps.card.core)
// endregion
// region Core modules
api(projects.core.analytics)
implementation(projects.core.analytics.models)
api(projects.core.utils)
implementation(projects.core.res)
// endregion
// region Domain models
api(projects.domain.models)
api(projects.domain.nft.models)
api(projects.domain.onramp.models)
api(projects.domain.staking.models)
api(projects.domain.txhistory.models)
api(projects.domain.visa.models)
api(projects.domain.walletConnect.models)
api(projects.domain.wallets.models)
api(projects.domain.yieldSupply.models)
// endregion
// region Tests
testImplementation(projects.test.core)
// endregion
}

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "442ac578743a8b624777711cf49c77e2",
"identityHash": "c0f8e7d958a8852440a5bdec5a989f0e",
"entities": [
{
"tableName": "express_provider",
@ -81,7 +81,7 @@
},
{
"tableName": "express_exchange",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `from_address` TEXT, `payin_address` TEXT NOT NULL, `payin_extra_id` TEXT, `payout_address` TEXT NOT NULL, `refund_address` TEXT, `refund_extra_id` TEXT, `rate_type` TEXT NOT NULL, `status` TEXT NOT NULL, `external_tx_id` TEXT, `external_tx_url` TEXT, `payin_hash` TEXT, `payout_hash` TEXT, `refund_network` TEXT, `refund_contract_address` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `pay_till` TEXT, `average_duration` INTEGER, `from_contract_address` TEXT NOT NULL, `from_network` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `from_amount` TEXT NOT NULL, `from_actual_amount` TEXT, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT NOT NULL, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `from_address` TEXT NOT NULL, `payin_address` TEXT NOT NULL, `payin_extra_id` TEXT, `payout_address` TEXT NOT NULL, `refund_address` TEXT, `refund_extra_id` TEXT, `rate_type` TEXT NOT NULL, `status` TEXT NOT NULL, `external_tx_id` TEXT, `external_tx_url` TEXT, `payin_hash` TEXT, `payout_hash` TEXT, `refund_network` TEXT, `refund_contract_address` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `pay_till` TEXT, `average_duration` INTEGER, `from_contract_address` TEXT NOT NULL, `from_network` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `from_amount` TEXT NOT NULL, `from_actual_amount` TEXT, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT NOT NULL, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`))",
"fields": [
{
"fieldPath": "txId",
@ -89,12 +89,6 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ownerAddress",
"columnName": "owner_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "providerId",
"columnName": "provider_id",
@ -104,7 +98,8 @@
{
"fieldPath": "fromAddress",
"columnName": "from_address",
"affinity": "TEXT"
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payinAddress",
@ -264,16 +259,16 @@
},
"indices": [
{
"name": "index_express_exchange_owner_address_from_network_from_contract_address_created_at",
"name": "index_express_exchange_from_address_from_network_from_contract_address_created_at",
"unique": false,
"columnNames": [
"owner_address",
"from_address",
"from_network",
"from_contract_address",
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_from_network_from_contract_address_created_at` ON `${TABLE_NAME}` (`owner_address`, `from_network`, `from_contract_address`, `created_at`)"
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_from_address_from_network_from_contract_address_created_at` ON `${TABLE_NAME}` (`from_address`, `from_network`, `from_contract_address`, `created_at`)"
},
{
"name": "index_express_exchange_to_network_to_contract_address_created_at",
@ -290,7 +285,7 @@
},
{
"tableName": "express_onramp",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `payout_address` TEXT NOT NULL, `status` TEXT NOT NULL, `fail_reason` TEXT, `external_tx_id` TEXT, `external_tx_url` TEXT, `payout_hash` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `from_currency_code` TEXT NOT NULL, `from_amount` TEXT NOT NULL, `from_precision` INTEGER NOT NULL, `payment_method` TEXT NOT NULL, `country_code` TEXT NOT NULL, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `payout_address` TEXT NOT NULL, `status` TEXT NOT NULL, `fail_reason` TEXT, `external_tx_id` TEXT, `external_tx_url` TEXT, `payout_hash` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `from_currency_code` TEXT NOT NULL, `from_amount` TEXT NOT NULL, `from_precision` INTEGER NOT NULL, `payment_method` TEXT NOT NULL, `country_code` TEXT NOT NULL, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`))",
"fields": [
{
"fieldPath": "txId",
@ -298,12 +293,6 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ownerAddress",
"columnName": "owner_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "providerId",
"columnName": "provider_id",
@ -421,16 +410,16 @@
},
"indices": [
{
"name": "index_express_onramp_owner_address_to_network_to_contract_address_created_at",
"name": "index_express_onramp_payout_address_to_network_to_contract_address_created_at",
"unique": false,
"columnNames": [
"owner_address",
"payout_address",
"to_network",
"to_contract_address",
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_owner_address_to_network_to_contract_address_created_at` ON `${TABLE_NAME}` (`owner_address`, `to_network`, `to_contract_address`, `created_at`)"
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_payout_address_to_network_to_contract_address_created_at` ON `${TABLE_NAME}` (`payout_address`, `to_network`, `to_contract_address`, `created_at`)"
}
]
},
@ -474,11 +463,194 @@
"address"
]
}
},
{
"tableName": "onramp_country",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`code` TEXT NOT NULL, `name` TEXT NOT NULL, `image` TEXT NOT NULL, `alpha3` TEXT NOT NULL, `continent` TEXT NOT NULL, `onramp_available` INTEGER NOT NULL, `currency_name` TEXT NOT NULL, `currency_code` TEXT NOT NULL, `currency_image` TEXT, `currency_precision` INTEGER NOT NULL, `currency_unit` TEXT NOT NULL, PRIMARY KEY(`code`))",
"fields": [
{
"fieldPath": "code",
"columnName": "code",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "image",
"columnName": "image",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "alpha3",
"columnName": "alpha3",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "continent",
"columnName": "continent",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isOnrampAvailable",
"columnName": "onramp_available",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultCurrency.name",
"columnName": "currency_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultCurrency.code",
"columnName": "currency_code",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultCurrency.image",
"columnName": "currency_image",
"affinity": "TEXT"
},
{
"fieldPath": "defaultCurrency.precision",
"columnName": "currency_precision",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultCurrency.unit",
"columnName": "currency_unit",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"code"
]
}
},
{
"tableName": "token_info",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`network_id` TEXT NOT NULL, `contract_address` TEXT NOT NULL, `coin_id` TEXT NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`network_id`, `contract_address`))",
"fields": [
{
"fieldPath": "networkId",
"columnName": "network_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contractAddress",
"columnName": "contract_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "coinId",
"columnName": "coin_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "symbol",
"columnName": "symbol",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "decimals",
"columnName": "decimals",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"network_id",
"contract_address"
]
}
},
{
"tableName": "history_index",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `entity_id` TEXT NOT NULL, `address` TEXT NOT NULL, `sort_time_millis` INTEGER NOT NULL, PRIMARY KEY(`type`, `entity_id`, `address`))",
"fields": [
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entity_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortTimeMillis",
"columnName": "sort_time_millis",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"type",
"entity_id",
"address"
]
},
"indices": [
{
"name": "index_history_index_address_sort_time_millis_entity_id",
"unique": false,
"columnNames": [
"address",
"sort_time_millis",
"entity_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_history_index_address_sort_time_millis_entity_id` ON `${TABLE_NAME}` (`address`, `sort_time_millis`, `entity_id`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '442ac578743a8b624777711cf49c77e2')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c0f8e7d958a8852440a5bdec5a989f0e')"
]
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.datasource.api.addressbook
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookResponse
import com.tangem.datasource.api.common.response.ApiResponse
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.PUT
import retrofit2.http.POST
import retrofit2.http.Path
interface AddressBookApi {
@POST("v1/address-books/sync")
suspend fun syncAddressBooks(@Body body: SyncAddressBooksRequest): ApiResponse<SyncAddressBooksResponse>
@PUT("v1/address-books/{walletId}")
suspend fun updateAddressBook(
@Path("walletId") walletId: String,
@Header("If-Match") eTag: String?,
@Body body: UpdateAddressBookRequest,
): ApiResponse<UpdateAddressBookResponse>
}

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for `POST /address-books/sync`.
*
* Each [Wallet.etag] is optional: when it matches the backend's etag, that wallet is omitted from the
* response and the local copy is kept.
*/
@JsonClass(generateAdapter = true)
data class SyncAddressBooksRequest(
@Json(name = "wallets") val wallets: List<Wallet>,
) {
@JsonClass(generateAdapter = true)
data class Wallet(
@Json(name = "walletId") val walletId: String,
@Json(name = "etag") val etag: String? = null,
)
}

View file

@ -0,0 +1,27 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response body for `POST /address-books/sync`.
*
* [items] contains only the wallets whose backend etag differs from the one sent in the request; wallets
* with a matching etag are omitted and their local copy must be kept.
*/
@JsonClass(generateAdapter = true)
data class SyncAddressBooksResponse(
@Json(name = "items") val items: List<Item>,
) {
@JsonClass(generateAdapter = true)
data class Item(
@Json(name = "walletId") val walletId: String,
@Json(name = "etag") val etag: String,
@Json(name = "version") val version: String,
@Json(name = "updatedAt") val updatedAt: String,
@Json(name = "nonce") val nonce: String,
@Json(name = "ciphertext") val ciphertext: String,
@Json(name = "authTag") val authTag: String,
)
}

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Request body for `PUT /address-books/{walletId}`. */
@JsonClass(generateAdapter = true)
data class UpdateAddressBookRequest(
@Json(name = "version") val version: String,
@Json(name = "nonce") val nonce: String,
@Json(name = "ciphertext") val ciphertext: String,
@Json(name = "authTag") val authTag: String,
)

View file

@ -0,0 +1,12 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Response body for `PUT /address-books/{walletId}`. */
@JsonClass(generateAdapter = true)
data class UpdateAddressBookResponse(
@Json(name = "walletId") val walletId: String,
@Json(name = "etag") val etag: String,
@Json(name = "updatedAt") val updatedAt: String,
)

View file

@ -4,6 +4,7 @@ import com.tangem.datasource.api.auth.models.request.AuthApiRequest
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.request.RegisterApiRequest
import com.tangem.datasource.api.auth.models.request.WalletRegistrationRequest
import com.tangem.datasource.api.auth.models.response.NonceApiResponse
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
import com.tangem.datasource.api.common.response.ApiResponse
@ -30,8 +31,7 @@ interface AuthApi {
* session token pair. Called once per app install.
*/
@POST("api/v1/auth/register")
@RequiresDpopProof
suspend fun register(@Body request: RegisterApiRequest): ApiResponse<TokenApiResponse>
suspend fun registerDevice(@Body request: RegisterApiRequest): ApiResponse<TokenApiResponse>
/**
* Request authentication nonce.
@ -61,4 +61,23 @@ interface AuthApi {
@POST("api/v1/auth/refresh")
@RequiresDpopProof
suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse<TokenApiResponse>
/**
* Request wallet registration nonce.
*
* Generates a nonce bound to the device public key for the wallet registration flow.
*/
@POST("api/v1/auth/nonce/wallet")
suspend fun requestWalletNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/**
* Register a wallet.
*
* Binds a new wallet to an already-registered device. When a card signature is provided the
* wallet is bound as COLD (card-backed); otherwise it is registered as a MOBILE (hot) wallet.
* Returns refreshed session tokens reflecting the updated wallet list.
*/
@POST("api/v1/auth/wallet")
@RequiresDpopProof
suspend fun registerWallet(@Body request: WalletRegistrationRequest): ApiResponse<TokenApiResponse>
}

View file

@ -10,17 +10,17 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class DeviceMetadata(
/** Device hardware model (e.g. `iPhone 15 Pro`). */
@Json(name = "deviceModel") val deviceModel: String?,
@Json(name = "deviceModel") val deviceModel: String,
/** Operating system (`android` / `ios`). */
@Json(name = "os") val os: String,
/** OS version string (e.g. `17.4.1`). */
@Json(name = "osVersion") val osVersion: String?,
@Json(name = "osVersion") val osVersion: String,
/** Application version (e.g. `5.8.0`). */
@Json(name = "appVersion") val appVersion: String?,
@Json(name = "appVersion") val appVersion: String,
/** User-Agent header (e.g. `Tangem/5.8.0 (iPhone; iOS 17.4.1; Scale/3.00)`). */
@Json(name = "userAgent") val userAgent: String?,
@Json(name = "userAgent") val userAgent: String,
/** Client locale (e.g. `en-US`). */
@Json(name = "locale") val locale: String?,
@Json(name = "locale") val locale: String,
/** Client timezone (e.g. `Europe/Moscow`). */
@Json(name = "timezone") val timezone: String?,
@Json(name = "timezone") val timezone: String,
)

View file

@ -0,0 +1,46 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Wallet registration request binds a new wallet to an already-registered device.
*
* When [cardSignature] (and the accompanying [cardSignatureSalt] / [walletStatus]) is provided the
* wallet is bound as COLD (card-backed); otherwise it is registered as a MOBILE (hot) wallet.
* Mirrors the `WalletRegistrationRequest` schema in the backend OpenAPI contract.
*/
@JsonClass(generateAdapter = true)
data class WalletRegistrationRequest(
/** Deciphered nonce value from `/api/v1/auth/nonce/wallet`. */
@Json(name = "nonce") val nonce: String,
/**
* Wallet identifier Base64-encoded
* `HMAC-SHA256(key = SHA-256(walletPublicKey), data = "UserWalletID")`.
*/
@Json(name = "walletId") val walletId: String,
/**
* Base64-encoded secp256k1 RSV signature (65 bytes) over `sha256(nonce || walletSignatureSalt)`.
* The server recovers `walletPublicKey` from this signature.
*/
@Json(name = "walletSignature") val walletSignature: String,
/** Base64-encoded salt used in the wallet signature hash. */
@Json(name = "walletSignatureSalt") val walletSignatureSalt: String,
/**
* Base64-encoded secp256k1 RSV signature (65 bytes) over
* `sha256(walletPublicKey || nonce || cardSignatureSalt || walletStatus)`. Required for
* cold-wallet registration; `null` for mobile (hot) wallets.
*/
@Json(name = "cardSignature") val cardSignature: String?,
/** Base64-encoded salt used in the card signature hash. Required for cold-wallet registration. */
@Json(name = "cardSignatureSalt") val cardSignatureSalt: String?,
/**
* Base64-encoded single byte describing wallet provenance on the card
* (`0x82` = generated on card, `0xC2` = SEED imported). Required for cold-wallet registration.
*/
@Json(name = "walletStatus") val walletStatus: String?,
/** Platform attestation token (Play Integrity / App Attest). */
@Json(name = "attestationToken") val attestationToken: String?,
/** Client-reported device metadata. */
@Json(name = "metadata") val metadata: DeviceMetadata,
)

View file

@ -55,6 +55,7 @@ internal sealed class TangemPay(
"version" to ProviderSuspend { appInfoProvider.appVersion },
"platform" to ProviderSuspend { "Android" },
"X-API-KEY" to ProviderSuspend { getBffStaticToken(apiEnvironment) },
"X-Device-Scale" to ProviderSuspend { appInfoProvider.deviceScale.toString() },
)
private fun getBffStaticToken(apiEnvironment: ApiEnvironment): String {

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.api.gasless
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionRequest
import com.tangem.datasource.api.gasless.models.GaslessServiceResponse
import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO
import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest
import retrofit2.http.Body
import retrofit2.http.POST
interface GaslessTxServiceApiV2 {
@POST("api/v2/transaction/sign")
suspend fun signGaslessTransaction(
@Body transaction: GaslessTransactionRequest,
): ApiResponse<GaslessServiceResponse<GaslessSignedTransactionResultDTO>>
@POST("api/v2/transaction/batch-sign")
suspend fun signGaslessBatchTransaction(
@Body transaction: GaslessBatchTransactionRequest,
): ApiResponse<GaslessServiceResponse<GaslessSignedTransactionResultDTO>>
}

View file

@ -0,0 +1,41 @@
package com.tangem.datasource.api.gasless.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for gasless batch transaction submission (v2 `POST /api/v2/transaction/batch-sign`).
* Represents a batch of transactions with fee delegation metadata.
*
* The top-level payload field is `gaslessTransaction` (shared shape with single sign see
* gasless-service `BatchSignRequestDto`), carrying `transactions[]`, `fee`, `nonce`.
*/
@JsonClass(generateAdapter = true)
data class GaslessBatchTransactionRequest(
@Json(name = "gaslessTransaction")
val gaslessTransaction: GaslessBatchTransactionDataDTO,
@Json(name = "signature")
val signature: String,
@Json(name = "userAddress")
val userAddress: String,
@Json(name = "chainId")
val chainId: Int,
@Json(name = "eip7702auth")
val eip7702Auth: Eip7702AuthorizationDTO? = null,
)
@JsonClass(generateAdapter = true)
data class GaslessBatchTransactionDataDTO(
@Json(name = "transactions")
val transactions: List<TransactionData>,
@Json(name = "fee")
val fee: FeeData,
@Json(name = "nonce")
val nonce: String,
)

View file

@ -45,6 +45,9 @@ data class TransactionData(
@Json(name = "value")
val value: String,
@Json(name = "gasLimit")
val gasLimit: String? = null,
@Json(name = "data")
val data: String,
)

View file

@ -17,12 +17,30 @@ interface TangemPayApi {
@Query("limit") limit: Int = TX_HISTORY_PAGING_DEFAULT_LIMIT,
): ApiResponse<TangemPayTxHistoryResponse>
@GET("v1/customer/transactions/{transaction_id}")
suspend fun getCustomerTransaction(
@Header("Authorization") authHeader: String,
@Path("transaction_id") transactionId: String,
): ApiResponse<TangemPayTransactionResponse>
@GET("v1/customer/kyc")
suspend fun getKycAccess(@Header("Authorization") authHeader: String): ApiResponse<KycAccessInfoResponse>
@GET("v1/customer/me")
suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse<CustomerMeResponse>
@GET("v1/customer/tariff-plan/transitions")
suspend fun getTariffPlanTransitions(
@Header("Authorization") authHeader: String,
): ApiResponse<TariffPlanTransitionsResponse>
/** Fiat bank requisites for the Virtual Account on-ramp (VA MVP0, TWI-1638). */
@GET("v1/account/bank-credentials/{product_instance_id}")
suspend fun getBankCredentials(
@Header("Authorization") authHeader: String,
@Path("product_instance_id") productInstanceId: String,
): ApiResponse<BankCredentialsResponse>
@GET("v1/customer/wallets/{customer_wallet_id}")
suspend fun checkCustomerWalletId(
@Path("customer_wallet_id") customerWalletId: String,
@ -40,6 +58,12 @@ interface TangemPayApi {
@GET("v1/eligibility/channels")
suspend fun getEligibilityChannels(): ApiResponse<TangemPayEligibilityChannels>
/** Eligibility channels fetched with the user (customer-wallet) token (VA MVP0, TWI-1638). */
@GET("v1/eligibility/channels")
suspend fun getUserEligibilityChannels(
@Header("Authorization") authHeader: String,
): ApiResponse<TangemPayEligibilityChannels>
@GET("v1/order/{order_id}")
suspend fun getOrder(
@Header("Authorization") authHeader: String,

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response of `bff-v2/v1/account/bank-credentials/{product_instance_id}` fiat bank requisites for the
* Virtual Account on-ramp (VA MVP0, TWI-1638).
*/
@JsonClass(generateAdapter = true)
data class BankCredentialsResponse(
@Json(name = "type") val type: String?,
@Json(name = "beneficiary_name") val beneficiaryName: String?,
@Json(name = "beneficiary_address") val beneficiaryAddress: String?,
@Json(name = "beneficiary_bank_name") val beneficiaryBankName: String?,
@Json(name = "beneficiary_bank_address") val beneficiaryBankAddress: String?,
@Json(name = "account_number") val accountNumber: String?,
@Json(name = "routing_number") val routingNumber: String?,
)

View file

@ -21,6 +21,39 @@ data class CustomerMeResponse(
@Json(name = "balance") val balance: BalanceResponse?,
@Json(name = "product_instances") val productInstances: List<ProductInstance>,
@Json(name = "cards") val cards: List<Card>,
@Json(name = "customer_tariff_plan") val customerTariffPlan: CustomerTariffPlan? = null,
)
@JsonClass(generateAdapter = true)
data class CustomerTariffPlan(
@Json(name = "status") val status: String?,
@Json(name = "next_billing_at") val nextBillingAt: String?,
@Json(name = "pending_transition_at") val pendingTransitionAt: String?,
@Json(name = "tariff_plan") val tariffPlan: TariffPlan?,
@Json(name = "pending_tariff_plan") val pendingTariffPlan: TariffPlan?,
)
@JsonClass(generateAdapter = true)
data class TariffPlan(
@Json(name = "id") val id: String?,
@Json(name = "type") val type: String?,
@Json(name = "name") val name: String?,
@Json(name = "description_items") val descriptionItems: List<DescriptionItem>?,
@Json(name = "images") val images: List<Image>? = null,
)
@JsonClass(generateAdapter = true)
data class Image(
@Json(name = "type") val type: String?,
@Json(name = "url") val url: String?,
)
@JsonClass(generateAdapter = true)
data class DescriptionItem(
@Json(name = "type") val type: String?,
@Json(name = "order") val order: Int?,
@Json(name = "title") val title: String?,
@Json(name = "body") val body: String?,
)
@JsonClass(generateAdapter = true)
@ -35,7 +68,17 @@ data class CustomerMeResponse(
@Json(name = "display_name") val displayName: String?,
@Json(name = "actual_card_limit") val actualCardLimit: CardLimit?,
@Json(name = "admin_card_limit") val adminCardLimit: CardLimit?,
@Json(name = "product_specification_data_type") val specificationDataType: SpecificationDataType,
) {
@JsonClass(generateAdapter = false)
enum class SpecificationDataType {
@Json(name = "ACCOUNT")
ACCOUNT,
@Json(name = "CARD")
CARD,
}
@JsonClass(generateAdapter = false)
enum class Status {
@Json(name = "NEW")

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Response of `GET v1/customer/transactions/{transaction_id}` — a single transaction by its id. */
@JsonClass(generateAdapter = true)
data class TangemPayTransactionResponse(
@Json(name = "result") val result: TangemPayTxHistoryResponse.Transaction,
)

View file

@ -44,6 +44,8 @@ data class TangemPayTxHistoryResponse(
@Json(name = "enriched_merchant_category") val enrichedMerchantCategory: String? = null,
@Json(name = "card_id") val cardId: String? = null,
@Json(name = "card_type") val cardType: String? = null,
@Json(name = "card_display_name") val cardDisplayName: String? = null,
@Json(name = "card_number_end") val cardNumberEnd: String? = null,
@Json(name = "status") val status: String,
@Json(name = "declined_reason") val declinedReason: String? = null,
@Json(name = "authorized_at") val authorizedAt: DateTime,

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 TariffPlanTransitionsResponse(
@Json(name = "result") val result: List<TariffPlanTransitionResponse>?,
)
@JsonClass(generateAdapter = true)
data class TariffPlanTransitionResponse(
@Json(name = "type") val type: String?,
@Json(name = "tariff_plan") val tariffPlan: CustomerMeResponse.TariffPlan?,
)

View file

@ -46,22 +46,25 @@ interface TangemTechApi {
@GET("v1/geo")
suspend fun getUserCountryCode(): GeoResponse
@GET("v1/application/versions")
suspend fun getApplicationVersions(): ApiResponse<ApplicationVersionsResponse>
@PUT("/v1/wallets/{walletId}/tokens")
suspend fun saveTokens(
@Path(value = "walletId") userId: String,
@Body userTokens: UserTokensResponse,
): ApiResponse<Unit>
@GET("/v1/wallets/{wallet_id}/notification-preferences")
@GET("/api/v1/notification-preferences/{wallet_id}")
suspend fun getPushNotificationPreferences(
@Path("wallet_id") walletId: String,
): ApiResponse<PushNotificationPreferencesResponse>
@PUT("/v1/wallets/{wallet_id}/notification-preferences")
@PUT("/api/v1/notification-preferences/{wallet_id}")
suspend fun updatePushNotificationPreferences(
@Path("wallet_id") walletId: String,
@Body body: PushNotificationPreferencesBody,
): ApiResponse<Unit>
): ApiResponse<PushNotificationPreferencesResponse>
// region Referral
/** Returns referral status by [walletId] */

View file

@ -0,0 +1,31 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response of `GET v1/application/versions`. All fields are nullable the backend may omit any, in
* which case the corresponding check is skipped.
*
* @property minSupportedVersion app version threshold for a mandatory update: if
* `installedVersion <= minSupportedVersion` the app must force-update (or show "update your OS"
* when [minSupportedOSVersion] is not met). Inclusive. E.g. "5.30".
* @property minSupportedOSVersion minimal device OS version required to install the update for the
* [minSupportedVersion] case: if `deviceOsVersion < minSupportedOSVersion` the device can't update
* and the "OS too old" screen is shown. Exclusive.
* @property criticalVersion app version threshold for a critical mandatory update: if
* `installedVersion <= criticalVersion` the app must force-update (or permanently "brick" when
* [criticalOSVersion] is not met). Inclusive.
* @property criticalOSVersion minimal device OS version required to install the critical update:
* if `deviceOsVersion < criticalOSVersion` the app is bricked (update impossible). Exclusive.
* @property latestVersion latest available app version: if `installedVersion < latestVersion`
* an optional update is offered. Exclusive. E.g. "5.40".
*/
@JsonClass(generateAdapter = true)
data class ApplicationVersionsResponse(
@Json(name = "minSupportedVersion") val minSupportedVersion: String?,
@Json(name = "minSupportedOSVersion") val minSupportedOSVersion: String?,
@Json(name = "criticalVersion") val criticalVersion: String?,
@Json(name = "criticalOSVersion") val criticalOSVersion: String?,
@Json(name = "latestVersion") val latestVersion: String?,
)

View file

@ -1,10 +0,0 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PushNotificationPreferenceState(
@Json(name = "isEnabled") val isEnabled: Boolean,
@Json(name = "isVisible") val isVisible: Boolean,
)

View file

@ -5,10 +5,10 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PushNotificationPreferencesBody(
@Json(name = "transactionAlerts")
val areTransactionAlertsEnabled: Boolean,
@Json(name = "offersUpdates")
val areOffersUpdatesEnabled: Boolean,
@Json(name = "priceAlerts")
@Json(name = "transactionEventsEnabled")
val areTransactionEventsEnabled: Boolean,
@Json(name = "offerUpdatesEnabled")
val areOfferUpdatesEnabled: Boolean,
@Json(name = "priceAlertsEnabled")
val arePriceAlertsEnabled: Boolean,
)

View file

@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PushNotificationPreferencesResponse(
@Json(name = "transactionAlerts") val transactionAlerts: PushNotificationPreferenceState,
@Json(name = "offersUpdates") val offersUpdates: PushNotificationPreferenceState,
@Json(name = "priceAlerts") val priceAlerts: PushNotificationPreferenceState,
@Json(name = "transactionEventsEnabled") val areTransactionEventsEnabled: Boolean,
@Json(name = "offerUpdatesEnabled") val areOfferUpdatesEnabled: Boolean,
@Json(name = "priceAlertsEnabled") val arePriceAlertsEnabled: Boolean,
)

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.addressbook.AddressBookApi
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.surveysparrow.SurveySparrowApi
@ -18,6 +19,7 @@ import com.tangem.datasource.api.news.NewsApi
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.TangemPayAuthApi
import com.tangem.datasource.api.stakekit.StakeKitApi
@ -117,6 +119,16 @@ internal object NetworkModule {
)
}
@Provides
@Singleton
fun provideAddressBookApi(retrofitApiBuilder: RetrofitApiBuilder): AddressBookApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemTech,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@Provides
@Singleton
fun provideYieldSupplyApi(retrofitApiBuilder: RetrofitApiBuilder): YieldSupplyApi {
@ -252,4 +264,20 @@ internal object NetworkModule {
),
)
}
@Provides
@Singleton
fun provideGaslessTxServiceApiV2(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApiV2 {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.GaslessTxService,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
readTimeoutSeconds = TIMEOUT_60_SECONDS,
writeTimeoutSeconds = TIMEOUT_60_SECONDS,
),
)
}
}

View file

@ -1,14 +1,27 @@
package com.tangem.datasource.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.txhistory.DefaultTxHistoryItemsStore
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.datasource.local.visa.DefaultTangemPayTxHistoryItemsStore
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemDM
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDMConverter
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDomainConverter
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import javax.inject.Singleton
@Module
@ -25,9 +38,34 @@ internal object TxHistoryItemsStoreModule {
@Provides
@Singleton
fun provideTangemPayTxHistoryItemsStore(): TangemPayTxHistoryItemsStore {
fun provideTangemPayTxHistoryItemsStore(
@ApplicationContext context: Context,
appScope: AppCoroutineScope,
toDMConverter: TangemPayTxHistoryItemToDMConverter,
toDomainConverter: TangemPayTxHistoryItemToDomainConverter,
): TangemPayTxHistoryItemsStore {
return DefaultTangemPayTxHistoryItemsStore(
dataStore = RuntimeDataStore(),
dataStore = DataStoreFactory.create(
serializer = KotlinxDataStoreSerializer(
defaultValue = emptyMap(),
serializer = MapSerializer(
keySerializer = String.serializer(),
valueSerializer = MapSerializer(
keySerializer = String.serializer(),
valueSerializer = ListSerializer(TangemPayTxHistoryItemDM.serializer()),
),
),
// TangemPayTxHistoryItemDM.Collateral declares a `type` field that would clash with the
// default kotlinx polymorphic class discriminator ("type"), so persist sealed subtypes
// under a non-conflicting discriminator key.
json = KotlinxDataStoreSerializer.jsonBuilder { classDiscriminator = "__type" },
),
corruptionHandler = ReplaceFileCorruptionHandler { emptyMap() },
produceFile = { context.dataStoreFile(fileName = "tangem_pay_tx_history") },
scope = appScope,
),
toDMConverter = toDMConverter,
toDomainConverter = toDomainConverter,
)
}
}

View file

@ -5,6 +5,8 @@ import androidx.room.Room
import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.dao.HistoryIndexDao
import com.tangem.datasource.local.txhistory.db.dao.TokenInfoDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -37,5 +39,11 @@ internal interface TxHistoryModule {
@Provides
fun provideSyncStateDao(database: TxHistoryDatabase): ExpressSyncStateDao = database.syncStateDao()
@Provides
fun provideTokenInfoDao(database: TxHistoryDatabase): TokenInfoDao = database.tokenInfoDao()
@Provides
fun provideHistoryIndexDao(database: TxHistoryDatabase): HistoryIndexDao = database.historyIndexDao()
}
}

View file

@ -7,13 +7,13 @@ import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEnti
/**
* Maps API history items into their persisted [androidx.room.Entity] representations.
*
* @param ownerAddress address the history was requested for. Stored as the query key.
*/
fun ExchangeItemResponse.toEntity(ownerAddress: String): ExpressExchangeEntity {
fun ExchangeItemResponse.toEntity(): ExpressExchangeEntity? {
// Items with no fromAddress (very old app versions didn't send it) can't be found by the outgoing-swap
// lookup, which keys on from_address — drop them. Such items are effectively nonexistent nowadays.
if (fromAddress == null) return null
return ExpressExchangeEntity(
txId = txId,
ownerAddress = ownerAddress,
providerId = providerId,
fromAddress = fromAddress,
payinAddress = payinAddress,
@ -50,10 +50,9 @@ fun ExchangeItemResponse.toEntity(ownerAddress: String): ExpressExchangeEntity {
)
}
fun OnrampItemResponse.toEntity(ownerAddress: String): ExpressOnrampEntity {
fun OnrampItemResponse.toEntity(): ExpressOnrampEntity {
return ExpressOnrampEntity(
txId = txId,
ownerAddress = ownerAddress,
providerId = providerId,
payoutAddress = payoutAddress,
status = status,

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.local.converter
import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
/** Maps an [OnrampCountryDTO] API response into its persisted [OnrampCountryEntity]. */
fun OnrampCountryDTO.toEntity(): OnrampCountryEntity {
return OnrampCountryEntity(
code = code,
name = name,
image = image,
alpha3 = alpha3,
continent = continent,
isOnrampAvailable = onrampAvailable,
defaultCurrency = OnrampCountryEntity.CurrencyEmbedded(
name = defaultCurrency.name,
code = defaultCurrency.code,
image = defaultCurrency.image,
precision = defaultCurrency.precision,
unit = defaultCurrency.unit ?: defaultCurrency.code,
),
)
}

View file

@ -33,6 +33,16 @@ object PreferencesKeys {
val APP_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "launchCount") }
val LAST_OPTIONAL_UPDATE_SHOWN_VERSION_KEY by lazy {
stringPreferencesKey(name = "lastOptionalUpdateShownVersion")
}
val LAST_OPTIONAL_UPDATE_SHOWN_AT_KEY by lazy { longPreferencesKey(name = "lastOptionalUpdateShownAt") }
val CACHED_APP_VERSIONS_KEY by lazy { stringPreferencesKey(name = "cachedApplicationVersions") }
val CACHED_APP_VERSIONS_AT_KEY by lazy { longPreferencesKey(name = "cachedApplicationVersionsAt") }
val SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "showRatingDialogAtLaunchCount") }
val FUNDS_FOUND_DATE_KEY by lazy { longPreferencesKey(name = "fundsFoundDate") }
@ -41,6 +51,8 @@ object PreferencesKeys {
val USED_CARDS_INFO_KEY by lazy { stringPreferencesKey(name = "usedCardsInfo_v2") }
val USEDESK_CLIENT_ID_KEY by lazy { stringPreferencesKey(name = "usedeskClientId") }
val APP_THEME_MODE_KEY by lazy { stringPreferencesKey(name = "appThemeMode") }
val SELECTED_APP_CURRENCY_KEY by lazy { stringPreferencesKey(name = "selectedAppCurrency") }
@ -154,7 +166,7 @@ object PreferencesKeys {
val TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY by lazy {
stringPreferencesKey(name = "tangemPayActiveWithdrawOrdersKey")
}
val TANGEM_PAY_ELIGIBILITY_KEY by lazy { stringSetPreferencesKey(name = "tangemPayEligibilityList") }
val TANGEM_PAY_ELIGIBILITY_KEY by lazy { stringSetPreferencesKey(name = "tangemPayEligibilityListV2") }
fun getShouldShowNotificationKey(key: String) = booleanPreferencesKey("showShowNotificationUM_$key")
// endregion

View file

@ -4,10 +4,15 @@ import androidx.room.Database
import androidx.room.RoomDatabase
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.dao.HistoryIndexDao
import com.tangem.datasource.local.txhistory.db.dao.TokenInfoDao
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
@Database(
version = 1,
@ -16,6 +21,9 @@ import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEn
ExpressExchangeEntity::class,
ExpressOnrampEntity::class,
ExpressSyncStateEntity::class,
OnrampCountryEntity::class,
TokenInfoEntity::class,
HistoryIndexEntity::class,
],
)
abstract class TxHistoryDatabase : RoomDatabase() {
@ -23,4 +31,8 @@ abstract class TxHistoryDatabase : RoomDatabase() {
abstract fun expressHistoryDao(): ExpressHistoryDao
abstract fun syncStateDao(): ExpressSyncStateDao
abstract fun tokenInfoDao(): TokenInfoDao
abstract fun historyIndexDao(): HistoryIndexDao
}

View file

@ -8,6 +8,7 @@ import androidx.room.Query
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
import kotlinx.coroutines.flow.Flow
@Dao
@ -22,15 +23,22 @@ interface ExpressHistoryDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertOnramps(items: List<ExpressOnrampEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertCountries(items: List<OnrampCountryEntity>)
/**
* All persisted providers keyed by [ExpressProviderEntity.id]
*/
@Query("SELECT * FROM express_provider")
fun getProvidersById(): Flow<Map<@MapColumn(columnName = "id") String, ExpressProviderEntity>>
/** All persisted onramp countries keyed by [OnrampCountryEntity.code]. */
@Query("SELECT * FROM onramp_country")
fun getCountriesByCode(): Flow<Map<@MapColumn(columnName = "code") String, OnrampCountryEntity>>
/**
* Outgoing swaps: the viewed currency is the swap's `from` side, so the row is stored under this
* address ([ExpressExchangeEntity.ownerAddress] == fromAddress). Join to on-chain by `payin_hash`.
* Outgoing swaps: the viewed currency is the swap's `from` side, so the row is looked up by its `from_address`.
* Join to on-chain by `payin_hash`.
*
* loading the whole table; [activeStatuses] keeps in-progress deals visible even outside the window.
@ -38,7 +46,7 @@ interface ExpressHistoryDao {
@Query(
"""
SELECT * FROM express_exchange
WHERE owner_address = :ownerAddress
WHERE from_address = :fromAddress
AND from_network = :network
AND from_contract_address = :contract
AND (created_at >= :fromCreatedAtIso OR status IN (:activeStatuses))
@ -46,7 +54,7 @@ interface ExpressHistoryDao {
""",
)
fun observeOutgoingSwaps(
ownerAddress: String,
fromAddress: String,
network: String,
contract: String,
fromCreatedAtIso: String,
@ -55,8 +63,8 @@ interface ExpressHistoryDao {
/**
* Incoming swaps: the viewed currency is the swap's `to` side. Such a deal was initiated from a
* different coin, so the row is stored under that coin's `owner_address` hence this query is
* cross-owner, matched by the `to` asset. Join to on-chain by `payout_hash`.
* different coin, so the row is stored under that coin's `from_address` hence this query is
* cross-address, matched by the `to` asset. Join to on-chain by `payout_hash`.
*/
@Query(
"""
@ -75,12 +83,12 @@ interface ExpressHistoryDao {
): Flow<List<ExpressExchangeEntity>>
/**
* Onramp is always incoming: [ExpressOnrampEntity.ownerAddress] == payoutAddress. Join by `payout_hash`.
* Onramp is always incoming, looked up by its `payout_address`. Join by `payout_hash`.
*/
@Query(
"""
SELECT * FROM express_onramp
WHERE owner_address = :ownerAddress
WHERE payout_address = :payoutAddress
AND to_network = :network
AND to_contract_address = :contract
AND (created_at >= :fromCreatedAtIso OR status IN (:activeStatuses))
@ -88,7 +96,7 @@ interface ExpressHistoryDao {
""",
)
fun observeIncomingOnramps(
ownerAddress: String,
payoutAddress: String,
network: String,
contract: String,
fromCreatedAtIso: String,

View file

@ -0,0 +1,74 @@
package com.tangem.datasource.local.txhistory.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface HistoryIndexDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(items: List<HistoryIndexEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(item: HistoryIndexEntity)
/**
* One page of the unified timeline for [addresses] (usually one, but some token-details screens span several),
* newest first, **one row per operation**. An operation indexed under several of the queried [addresses] (e.g. a
* swap under both its from- and payout-address) is collapsed via `GROUP BY (type, entity_id)`, keeping its
* most-recent occurrence (SQLite bare-column rule under a single `MAX`). This both de-duplicates the timeline and
* makes the keyset key (`sort_time_millis`, `entity_id`) unique, so rows are neither skipped nor duplicated across
* page boundaries even when several addresses are queried.
*
* The cursor is the (`sort_time_millis`, `entity_id`) of the last (oldest) row of the previous page pass both
* [cursorSortTimeMillis] and [cursorEntityId], or `null` for the first page.
*/
@Query(
"""
SELECT type, entity_id, address, MAX(sort_time_millis) AS sort_time_millis FROM history_index
WHERE address IN (:addresses)
GROUP BY type, entity_id
HAVING (
:cursorSortTimeMillis IS NULL
OR MAX(sort_time_millis) < :cursorSortTimeMillis
OR (MAX(sort_time_millis) = :cursorSortTimeMillis AND entity_id < :cursorEntityId)
)
ORDER BY sort_time_millis DESC, entity_id DESC
LIMIT :limit
""",
)
fun observePage(
addresses: List<String>,
cursorSortTimeMillis: Long?,
cursorEntityId: String?,
limit: Int,
): Flow<List<HistoryIndexEntity>>
fun observePage(addresses: List<String>, cursor: Cursor?, limit: Int): Flow<List<HistoryIndexEntity>> = observePage(
addresses = addresses,
cursorSortTimeMillis = cursor?.sortTimeMillis,
cursorEntityId = cursor?.entityId,
limit = limit,
)
/**
* Keyset cursor for [observePage]: the (sortTimeMillis, entityId) of the last (oldest) row of a page. Build it from
* the previous page's last row to fetch the next page; a `null` cursor requests the first page.
*/
data class Cursor(
val sortTimeMillis: Long,
val entityId: String,
) {
companion object {
fun from(lastRow: HistoryIndexEntity): Cursor = Cursor(
sortTimeMillis = lastRow.sortTimeMillis,
entityId = lastRow.entityId,
)
}
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.datasource.local.txhistory.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
@Dao
interface TokenInfoDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(items: List<TokenInfoEntity>)
/**
* Cached tokens for the given networks/contracts. Filters each column independently, so the result is a
* cross-product superset of the `(networkId, contractAddress)` pairs the caller must match exact pairs.
* Contract match is case-insensitive; pass [minUpdatedAt] = `now - ttl` to drop stale rows.
*/
@Query(
"""
SELECT * FROM token_info
WHERE network_id IN (:networkIds)
AND contract_address COLLATE NOCASE IN (:contractAddresses)
AND updated_at >= :minUpdatedAt
""",
)
suspend fun getCached(
networkIds: Collection<String>,
contractAddresses: Collection<String>,
minUpdatedAt: Long = 0,
): List<TokenInfoEntity>
}

View file

@ -0,0 +1,42 @@
package com.tangem.datasource.local.txhistory.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
/**
* Unified pagination index over the local history sources.
*/
@Entity(
tableName = "history_index",
// A single row (type + entity_id) may be shown under more than one address (e.g. a swap between two owned tokens
// appears under both), so the address is part of the identity.
primaryKeys = ["type", "entity_id", "address"],
indices = [
// Newest-first cursor scan within an address: address filter + sort-time ordering + entity_id tie-break.
Index(value = ["address", "sort_time_millis", "entity_id"]),
],
)
data class HistoryIndexEntity(
@ColumnInfo(name = "type")
val type: String,
/** ID of the row in its own table. */
@ColumnInfo(name = "entity_id")
val entityId: String,
/** Address the row is loaded under on the token-details screen. */
@ColumnInfo(name = "address")
val address: String,
/** Time the unified timeline is sorted by (newest first). */
@ColumnInfo(name = "sort_time_millis")
val sortTimeMillis: Long,
) {
enum class Type(val value: String) {
EXCHANGE(value = "EXCHANGE"),
ONRAMP(value = "ONRAMP"),
}
}

View file

@ -10,8 +10,8 @@ import androidx.room.*
@Entity(
tableName = "express_exchange",
indices = [
// Outgoing swaps lookup (observeOutgoingSwaps): owner + from-asset equality, created_at range/sort.
Index(value = ["owner_address", "from_network", "from_contract_address", "created_at"]),
// Outgoing swaps lookup (observeOutgoingSwaps): from-address + from-asset equality, created_at range/sort.
Index(value = ["from_address", "from_network", "from_contract_address", "created_at"]),
// Incoming (cross-owner) swaps lookup (observeIncomingSwaps): to-asset equality, created_at range/sort.
// No owner filter here, so to_contract_address in the index is what keeps a popular to-network selective.
Index(value = ["to_network", "to_contract_address", "created_at"]),
@ -23,21 +23,16 @@ data class ExpressExchangeEntity(
@ColumnInfo(name = "tx_id")
val txId: String,
/**
* Address used to query the history. For exchange it matches [fromAddress].
*/
@ColumnInfo(name = "owner_address")
val ownerAddress: String,
@ColumnInfo(name = "provider_id")
val providerId: String,
/**
* Address from which the `from` assets were taken for the exchange. Optional because the very first
* app versions did not send it; for newer versions it can be considered effectively mandatory.
* Address from which the `from` assets were taken the key outgoing swaps are looked up by. The API may omit it
* (the very first app versions did not send it), but such items are filtered out before persisting, so the stored
* value is always present.
*/
@ColumnInfo(name = "from_address")
val fromAddress: String?,
val fromAddress: String,
/** Address to which the source assets were transferred for the exchange */
@ColumnInfo(name = "payin_address")

View file

@ -10,8 +10,8 @@ import androidx.room.*
@Entity(
tableName = "express_onramp",
indices = [
// Incoming onramp lookup (observeIncomingOnramps): owner + to-asset equality, created_at range/sort.
Index(value = ["owner_address", "to_network", "to_contract_address", "created_at"]),
// Incoming onramp lookup (observeIncomingOnramps): payout-address + to-asset equality, created_at range/sort.
Index(value = ["payout_address", "to_network", "to_contract_address", "created_at"]),
],
)
data class ExpressOnrampEntity(
@ -20,16 +20,10 @@ data class ExpressOnrampEntity(
@ColumnInfo(name = "tx_id")
val txId: String,
/**
* Address used to query the history. For onramp it matches [payoutAddress].
*/
@ColumnInfo(name = "owner_address")
val ownerAddress: String,
@ColumnInfo(name = "provider_id")
val providerId: String,
/** Address that received the target assets */
/** Address that received the target assets — the key incoming onramps are looked up by. */
@ColumnInfo(name = "payout_address")
val payoutAddress: String,

View file

@ -0,0 +1,52 @@
package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
/** Persisted onramp country, matched to a transaction by [code] == [ExpressOnrampEntity.countryCode]. */
@Entity(tableName = "onramp_country")
data class OnrampCountryEntity(
@PrimaryKey
@ColumnInfo(name = "code")
val code: String,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "image")
val image: String,
@ColumnInfo(name = "alpha3")
val alpha3: String,
@ColumnInfo(name = "continent")
val continent: String,
@ColumnInfo(name = "onramp_available")
val isOnrampAvailable: Boolean,
@Embedded(prefix = "currency_")
val defaultCurrency: CurrencyEmbedded,
) {
data class CurrencyEmbedded(
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "code")
val code: String,
@ColumnInfo(name = "image")
val image: String?,
@ColumnInfo(name = "precision")
val precision: Int,
@ColumnInfo(name = "unit")
val unit: String,
)
}

View file

@ -0,0 +1,37 @@
package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.ColumnInfo
import androidx.room.Entity
/**
* Cached token data fetched from [TangemTechApi.getCoins], keyed by network id + contract address.
*/
@Entity(
tableName = "token_info",
primaryKeys = ["network_id", "contract_address"],
)
data class TokenInfoEntity(
@ColumnInfo(name = "network_id")
val networkId: String,
@ColumnInfo(name = "contract_address")
val contractAddress: String,
/** Coin id from the backend (used as the token's raw currency id and for the icon URL). */
@ColumnInfo(name = "coin_id")
val coinId: String,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "symbol")
val symbol: String,
@ColumnInfo(name = "decimals")
val decimals: Int,
/** Last refresh timestamp in epoch milliseconds, used to evict stale entries. */
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
)

View file

@ -10,15 +10,15 @@ internal class DefaultTangemPayCloseCardStore(
private val prefs: AppPreferencesStore,
) : TangemPayCloseCardStore {
override suspend fun setCloseOrderId(cardId: String, orderId: String?) {
if (orderId == null) {
prefs.edit { it.remove(getCloseKey(cardId)) }
} else {
prefs.store(
key = getCloseKey(cardId),
value = orderId,
)
}
override suspend fun storeCloseOrderId(cardId: String, orderId: String) {
prefs.store(
key = getCloseKey(cardId),
value = orderId,
)
}
override suspend fun removeCloseOrderId(cardId: String) {
prefs.edit { it.remove(getCloseKey(cardId)) }
}
override suspend fun getOrderId(cardId: String): String? {

View file

@ -1,5 +1,6 @@
package com.tangem.datasource.local.visa
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -31,6 +32,10 @@ internal class DefaultTangemPayReissueCardStore(
)
}
override suspend fun removeReissueOrderId(cardId: String) {
prefs.edit { it.remove(getReissueKey(cardId)) }
}
override suspend fun getOrderId(cardId: String): String? {
return prefs.getSyncOrNull(key = getReissueKey(cardId))
}

View file

@ -1,23 +1,37 @@
package com.tangem.datasource.local.visa
import com.tangem.datasource.local.datastore.core.StringKeyDataStore
import com.tangem.datasource.local.datastore.core.StringKeyDataStoreDecorator
import androidx.datastore.core.DataStore
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemDM
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDMConverter
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDomainConverter
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import kotlinx.coroutines.flow.first
internal typealias StoredTangemPayTxHistory = Map<String, Map<String, List<TangemPayTxHistoryItemDM>>>
internal class DefaultTangemPayTxHistoryItemsStore(
dataStore: StringKeyDataStore<Map<String, List<TangemPayTxHistoryItem>>>,
) : TangemPayTxHistoryItemsStore,
StringKeyDataStoreDecorator<String, Map<String, List<TangemPayTxHistoryItem>>>(dataStore) {
override fun provideStringKey(key: String): String = key
private val dataStore: DataStore<StoredTangemPayTxHistory>,
private val toDMConverter: TangemPayTxHistoryItemToDMConverter,
private val toDomainConverter: TangemPayTxHistoryItemToDomainConverter,
) : TangemPayTxHistoryItemsStore {
override suspend fun getSyncOrNull(key: String, cursor: String): List<TangemPayTxHistoryItem>? {
val storedValue = getSyncOrNull(key)
return storedValue?.get(cursor)
return dataStore.data.first()[key]?.get(cursor)?.let { toDomainConverter.convertList(it) }
}
override suspend fun store(key: String, cursor: String, value: List<TangemPayTxHistoryItem>) {
val oldValue = getSyncOrNull(key).orEmpty()
val newValue = oldValue.toMutableMap().apply { put(cursor, value) }
store(key, newValue)
val page = toDMConverter.convertList(value)
dataStore.updateData { stored ->
val walletPages = stored[key].orEmpty()
stored + (key to walletPages + (cursor to page))
}
}
override suspend fun remove(key: String) {
remove(keys = listOf(key))
}
override suspend fun remove(keys: List<String>) {
dataStore.updateData { stored -> stored - keys.toSet() }
}
}

View file

@ -2,7 +2,9 @@ package com.tangem.datasource.local.visa
interface TangemPayCloseCardStore {
suspend fun setCloseOrderId(cardId: String, orderId: String?)
suspend fun storeCloseOrderId(cardId: String, orderId: String)
suspend fun removeCloseOrderId(cardId: String)
suspend fun getOrderId(cardId: String): String?
}

View file

@ -11,5 +11,7 @@ interface TangemPayReissueCardStore {
suspend fun storeReissueOrderId(cardId: String, orderId: String)
suspend fun removeReissueOrderId(cardId: String)
suspend fun getOrderId(cardId: String): String?
}

View file

@ -8,5 +8,7 @@ interface TangemPayTxHistoryItemsStore {
suspend fun remove(key: String)
suspend fun remove(keys: List<String>)
suspend fun store(key: String, cursor: String, value: List<TangemPayTxHistoryItem>)
}

View file

@ -0,0 +1,102 @@
package com.tangem.datasource.local.visa.entity
import com.tangem.domain.models.serialization.SerializedBigDecimal
import com.tangem.domain.models.serialization.SerializedCurrency
import com.tangem.domain.models.serialization.SerializedDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal sealed class TangemPayTxHistoryItemDM {
abstract val id: String
abstract val date: SerializedDateTime
abstract val amount: SerializedBigDecimal
abstract val currency: SerializedCurrency
abstract val jsonRepresentation: String
@Serializable
@SerialName("spend")
data class Spend(
@SerialName("id") override val id: String,
@SerialName("json_representation") override val jsonRepresentation: String,
@SerialName("date") override val date: SerializedDateTime,
@SerialName("amount") override val amount: SerializedBigDecimal,
@SerialName("currency") override val currency: SerializedCurrency,
@SerialName("authorized_amount") val authorizedAmount: SerializedBigDecimal,
@SerialName("local_amount") val localAmount: SerializedBigDecimal?,
@SerialName("local_currency") val localCurrency: SerializedCurrency?,
@SerialName("enriched_merchant_name") val enrichedMerchantName: String?,
@SerialName("merchant_name") val merchantName: String,
@SerialName("enriched_merchant_category") val enrichedMerchantCategory: String?,
@SerialName("merchant_category_code") val merchantCategoryCode: String?,
@SerialName("merchant_category") val merchantCategory: String?,
@SerialName("status") val status: Status,
@SerialName("enriched_merchant_icon_url") val enrichedMerchantIconUrl: String?,
@SerialName("declined_reason") val declinedReason: String?,
) : TangemPayTxHistoryItemDM()
@Serializable
@SerialName("payment")
data class Payment(
@SerialName("id") override val id: String,
@SerialName("json_representation") override val jsonRepresentation: String,
@SerialName("date") override val date: SerializedDateTime,
@SerialName("amount") override val amount: SerializedBigDecimal,
@SerialName("currency") override val currency: SerializedCurrency,
@SerialName("transaction_hash") val transactionHash: String?,
) : TangemPayTxHistoryItemDM()
@Serializable
@SerialName("fee")
data class Fee(
@SerialName("id") override val id: String,
@SerialName("json_representation") override val jsonRepresentation: String,
@SerialName("date") override val date: SerializedDateTime,
@SerialName("amount") override val amount: SerializedBigDecimal,
@SerialName("currency") override val currency: SerializedCurrency,
@SerialName("description") val description: String?,
) : TangemPayTxHistoryItemDM()
@Serializable
@SerialName("collateral")
data class Collateral(
@SerialName("id") override val id: String,
@SerialName("json_representation") override val jsonRepresentation: String,
@SerialName("date") override val date: SerializedDateTime,
@SerialName("amount") override val amount: SerializedBigDecimal,
@SerialName("currency") override val currency: SerializedCurrency,
@SerialName("transaction_hash") val transactionHash: String,
@SerialName("type") val type: Type,
) : TangemPayTxHistoryItemDM()
@Serializable
enum class Type {
@SerialName("deposit")
Deposit,
@SerialName("withdrawal")
Withdrawal,
}
@Serializable
enum class Status {
@SerialName("pending")
PENDING,
@SerialName("reserved")
RESERVED,
@SerialName("completed")
COMPLETED,
@SerialName("declined")
DECLINED,
@SerialName("reversed")
REVERSED,
@SerialName("unknown")
UNKNOWN,
}
}

View file

@ -0,0 +1,133 @@
package com.tangem.datasource.local.visa.entity
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.utils.converter.Converter
import javax.inject.Inject
internal class TangemPayTxHistoryItemToDMConverter @Inject constructor() :
Converter<TangemPayTxHistoryItem, TangemPayTxHistoryItemDM> {
override fun convert(value: TangemPayTxHistoryItem): TangemPayTxHistoryItemDM = when (value) {
is TangemPayTxHistoryItem.Spend -> TangemPayTxHistoryItemDM.Spend(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
authorizedAmount = value.authorizedAmount,
localAmount = value.localAmount,
localCurrency = value.localCurrency,
enrichedMerchantName = value.enrichedMerchantName,
merchantName = value.merchantName,
enrichedMerchantCategory = value.enrichedMerchantCategory,
merchantCategoryCode = value.merchantCategoryCode,
merchantCategory = value.merchantCategory,
status = value.status.toDM(),
enrichedMerchantIconUrl = value.enrichedMerchantIconUrl,
declinedReason = value.declinedReason,
)
is TangemPayTxHistoryItem.Payment -> TangemPayTxHistoryItemDM.Payment(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
transactionHash = value.transactionHash,
)
is TangemPayTxHistoryItem.Fee -> TangemPayTxHistoryItemDM.Fee(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
description = value.description,
)
is TangemPayTxHistoryItem.Collateral -> TangemPayTxHistoryItemDM.Collateral(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
transactionHash = value.transactionHash,
type = value.type.toDM(),
)
}
}
internal class TangemPayTxHistoryItemToDomainConverter @Inject constructor() :
Converter<TangemPayTxHistoryItemDM, TangemPayTxHistoryItem> {
override fun convert(value: TangemPayTxHistoryItemDM): TangemPayTxHistoryItem = when (value) {
is TangemPayTxHistoryItemDM.Spend -> TangemPayTxHistoryItem.Spend(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
authorizedAmount = value.authorizedAmount,
localAmount = value.localAmount,
localCurrency = value.localCurrency,
enrichedMerchantName = value.enrichedMerchantName,
merchantName = value.merchantName,
enrichedMerchantCategory = value.enrichedMerchantCategory,
merchantCategoryCode = value.merchantCategoryCode,
merchantCategory = value.merchantCategory,
status = value.status.toDomain(),
enrichedMerchantIconUrl = value.enrichedMerchantIconUrl,
declinedReason = value.declinedReason,
)
is TangemPayTxHistoryItemDM.Payment -> TangemPayTxHistoryItem.Payment(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
transactionHash = value.transactionHash,
)
is TangemPayTxHistoryItemDM.Fee -> TangemPayTxHistoryItem.Fee(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
description = value.description,
)
is TangemPayTxHistoryItemDM.Collateral -> TangemPayTxHistoryItem.Collateral(
id = value.id,
jsonRepresentation = value.jsonRepresentation,
date = value.date,
amount = value.amount,
currency = value.currency,
transactionHash = value.transactionHash,
type = value.type.toDomain(),
)
}
}
private fun TangemPayTxHistoryItem.Status.toDM(): TangemPayTxHistoryItemDM.Status = when (this) {
TangemPayTxHistoryItem.Status.PENDING -> TangemPayTxHistoryItemDM.Status.PENDING
TangemPayTxHistoryItem.Status.RESERVED -> TangemPayTxHistoryItemDM.Status.RESERVED
TangemPayTxHistoryItem.Status.COMPLETED -> TangemPayTxHistoryItemDM.Status.COMPLETED
TangemPayTxHistoryItem.Status.DECLINED -> TangemPayTxHistoryItemDM.Status.DECLINED
TangemPayTxHistoryItem.Status.REVERSED -> TangemPayTxHistoryItemDM.Status.REVERSED
TangemPayTxHistoryItem.Status.UNKNOWN -> TangemPayTxHistoryItemDM.Status.UNKNOWN
}
private fun TangemPayTxHistoryItemDM.Status.toDomain(): TangemPayTxHistoryItem.Status = when (this) {
TangemPayTxHistoryItemDM.Status.PENDING -> TangemPayTxHistoryItem.Status.PENDING
TangemPayTxHistoryItemDM.Status.RESERVED -> TangemPayTxHistoryItem.Status.RESERVED
TangemPayTxHistoryItemDM.Status.COMPLETED -> TangemPayTxHistoryItem.Status.COMPLETED
TangemPayTxHistoryItemDM.Status.DECLINED -> TangemPayTxHistoryItem.Status.DECLINED
TangemPayTxHistoryItemDM.Status.REVERSED -> TangemPayTxHistoryItem.Status.REVERSED
TangemPayTxHistoryItemDM.Status.UNKNOWN -> TangemPayTxHistoryItem.Status.UNKNOWN
}
private fun TangemPayTxHistoryItem.Type.toDM(): TangemPayTxHistoryItemDM.Type = when (this) {
TangemPayTxHistoryItem.Type.Deposit -> TangemPayTxHistoryItemDM.Type.Deposit
TangemPayTxHistoryItem.Type.Withdrawal -> TangemPayTxHistoryItemDM.Type.Withdrawal
}
private fun TangemPayTxHistoryItemDM.Type.toDomain(): TangemPayTxHistoryItem.Type = when (this) {
TangemPayTxHistoryItemDM.Type.Deposit -> TangemPayTxHistoryItem.Type.Deposit
TangemPayTxHistoryItemDM.Type.Withdrawal -> TangemPayTxHistoryItem.Type.Withdrawal
}

View file

@ -70,6 +70,7 @@ internal class ProdApiConfigsManagerTest {
every { appInfoProvider.osVersion } returns "Android 16"
every { appInfoProvider.language } returns Locale.getDefault().toLanguageTag()
every { appInfoProvider.device } returns "${Build.MANUFACTURER} ${Build.MODEL}"
every { appInfoProvider.deviceScale } returns DEVICE_SCALE
manager = ProdApiConfigsManager(apiConfigs = createApiConfigs())
}
@ -298,6 +299,7 @@ internal class ProdApiConfigsManagerTest {
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },
"X-API-KEY" to ProviderSuspend { TANGEM_PAY_BFF_KEY_DEV },
"X-Device-Scale" to ProviderSuspend { DEVICE_SCALE.toString() },
),
),
)
@ -313,6 +315,7 @@ internal class ProdApiConfigsManagerTest {
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },
"X-API-KEY" to ProviderSuspend { TANGEM_PAY_BFF_KEY_DEV },
"X-Device-Scale" to ProviderSuspend { DEVICE_SCALE.toString() },
),
),
)
@ -457,6 +460,7 @@ internal class ProdApiConfigsManagerTest {
private companion object {
const val VERSION_NAME = "debug"
const val DEVICE_SCALE = 3f
const val EXPRESS_SESSION_ID = "express_session_id"
const val STAKE_KIT_API_KEY = "stake_kit_api_key"
const val P2P_API_KEY = "p2p_api_key"

View file

@ -15,11 +15,10 @@ internal class ExpressHistoryConverterTest {
val item = createExchangeItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = requireNotNull(item.toEntity())
// THEN
Truth.assertThat(entity.txId).isEqualTo(item.txId)
Truth.assertThat(entity.ownerAddress).isEqualTo(OWNER_ADDRESS)
Truth.assertThat(entity.providerId).isEqualTo(item.providerId)
Truth.assertThat(entity.fromAddress).isEqualTo(item.fromAddress)
Truth.assertThat(entity.payinAddress).isEqualTo(item.payinAddress)
@ -47,7 +46,7 @@ internal class ExpressHistoryConverterTest {
val item = createExchangeItem(status = "finished")
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = requireNotNull(item.toEntity())
// THEN
Truth.assertThat(entity.status).isEqualTo("finished")
@ -59,7 +58,7 @@ internal class ExpressHistoryConverterTest {
val item = createExchangeItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = requireNotNull(item.toEntity())
// THEN
Truth.assertThat(entity.from.contractAddress).isEqualTo(item.fromContractAddress)
@ -95,7 +94,7 @@ internal class ExpressHistoryConverterTest {
)
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = requireNotNull(item.toEntity())
// THEN
Truth.assertThat(entity.payinExtraId).isNull()
@ -112,17 +111,28 @@ internal class ExpressHistoryConverterTest {
Truth.assertThat(entity.to.actualAmount).isNull()
}
@Test
fun `GIVEN exchange item with null fromAddress WHEN toEntity THEN returns null`() {
// GIVEN
val item = createExchangeItem().copy(fromAddress = null)
// WHEN
val entity = item.toEntity()
// THEN
Truth.assertThat(entity).isNull()
}
@Test
fun `GIVEN onramp item WHEN toEntity THEN all transaction fields are mapped`() {
// GIVEN
val item = createOnrampItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = item.toEntity()
// THEN
Truth.assertThat(entity.txId).isEqualTo(item.txId)
Truth.assertThat(entity.ownerAddress).isEqualTo(OWNER_ADDRESS)
Truth.assertThat(entity.providerId).isEqualTo(item.providerId)
Truth.assertThat(entity.payoutAddress).isEqualTo(item.payoutAddress)
Truth.assertThat(entity.failReason).isEqualTo(item.failReason)
@ -145,7 +155,7 @@ internal class ExpressHistoryConverterTest {
val item = createOnrampItem(status = "waiting-for-payment")
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = item.toEntity()
// THEN
Truth.assertThat(entity.status).isEqualTo("waiting-for-payment")
@ -157,7 +167,7 @@ internal class ExpressHistoryConverterTest {
val item = createOnrampItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = item.toEntity()
// THEN
Truth.assertThat(entity.to.contractAddress).isEqualTo(item.toContractAddress)
@ -180,7 +190,7 @@ internal class ExpressHistoryConverterTest {
)
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
val entity = item.toEntity()
// THEN
Truth.assertThat(entity.failReason).isNull()
@ -269,8 +279,4 @@ internal class ExpressHistoryConverterTest {
paymentMethod = "card",
countryCode = "US",
)
private companion object {
const val OWNER_ADDRESS = "0xowner"
}
}

View file

@ -0,0 +1,88 @@
package com.tangem.datasource.local.visa
import androidx.datastore.preferences.core.emptyPreferences
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.models.pay.TangemPayReissueCardFee
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Tests for [DefaultTangemPayReissueCardStore], focused on the reissue order-id lifecycle.
*
* Uses a real [AppPreferencesStore] backed by an in-memory [MockStateDataStore] so the preferences
* round-trip (store / read / remove) is exercised end-to-end. The remove path backs the [REDACTED_TASK_KEY] fix:
* a terminal reissue order must be forgotten so the payment-account refresh stops re-polling
* `GET /order/{id}` for it.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultTangemPayReissueCardStoreTest {
private val dataStore = MockStateDataStore(default = emptyPreferences())
private val prefs = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = dataStore,
)
private val feeStore: RuntimeDataStore<TangemPayReissueCardFee> = mockk(relaxed = true)
private val store = DefaultTangemPayReissueCardStore(feeStore = feeStore, prefs = prefs)
@BeforeEach
fun resetStore() {
runBlocking { dataStore.updateData { emptyPreferences() } }
}
@Test
fun `GIVEN order id stored WHEN getOrderId THEN returns stored id`() = runTest {
// Arrange
store.storeReissueOrderId(CARD_ID, ORDER_ID)
// Act
val result = store.getOrderId(CARD_ID)
// Assert
assertThat(result).isEqualTo(ORDER_ID)
}
@Test
fun `GIVEN order id stored WHEN removeReissueOrderId THEN order id is cleared`() = runTest {
// Arrange
store.storeReissueOrderId(CARD_ID, ORDER_ID)
// Act
store.removeReissueOrderId(CARD_ID)
// Assert
assertThat(store.getOrderId(CARD_ID)).isNull()
}
@Test
fun `GIVEN clearing one card WHEN another card has an order THEN the other is untouched`() = runTest {
// Arrange
store.storeReissueOrderId(CARD_ID, ORDER_ID)
store.storeReissueOrderId(OTHER_CARD_ID, OTHER_ORDER_ID)
// Act
store.removeReissueOrderId(CARD_ID)
// Assert
assertThat(store.getOrderId(CARD_ID)).isNull()
assertThat(store.getOrderId(OTHER_CARD_ID)).isEqualTo(OTHER_ORDER_ID)
}
private companion object {
const val CARD_ID = "card-1"
const val OTHER_CARD_ID = "card-2"
const val ORDER_ID = "reissue-order-1"
const val OTHER_ORDER_ID = "reissue-order-2"
}
}

View file

@ -0,0 +1,119 @@
package com.tangem.datasource.local.visa
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDMConverter
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDomainConverter
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.test.core.datastore.MockStateDataStore
import kotlinx.coroutines.test.runTest
import org.joda.time.DateTime
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.util.Currency
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultTangemPayTxHistoryItemsStoreTest {
private lateinit var store: DefaultTangemPayTxHistoryItemsStore
@BeforeEach
fun setup() {
store = DefaultTangemPayTxHistoryItemsStore(
dataStore = MockStateDataStore(default = emptyMap()),
toDMConverter = TangemPayTxHistoryItemToDMConverter(),
toDomainConverter = TangemPayTxHistoryItemToDomainConverter(),
)
}
@Test
fun `GIVEN empty store WHEN getSyncOrNull THEN returns null`() = runTest {
val result = store.getSyncOrNull(key = WALLET_A, cursor = CURSOR_1)
assertThat(result).isNull()
}
@Test
fun `GIVEN stored page WHEN getSyncOrNull with same key and cursor THEN returns page`() = runTest {
// Arrange
val page = listOf(payment("1"), payment("2"))
store.store(key = WALLET_A, cursor = CURSOR_1, value = page)
// Act
val result = store.getSyncOrNull(key = WALLET_A, cursor = CURSOR_1)
// Assert
assertThat(result).isEqualTo(page)
}
@Test
fun `GIVEN page stored under one cursor WHEN getSyncOrNull with another cursor THEN returns null`() = runTest {
// Arrange
store.store(key = WALLET_A, cursor = CURSOR_1, value = listOf(payment("1")))
// Act
val result = store.getSyncOrNull(key = WALLET_A, cursor = CURSOR_2)
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN two cursors stored for one wallet WHEN getSyncOrNull THEN both pages are kept`() = runTest {
// Arrange
val page1 = listOf(payment("1"))
val page2 = listOf(payment("2"))
store.store(key = WALLET_A, cursor = CURSOR_1, value = page1)
store.store(key = WALLET_A, cursor = CURSOR_2, value = page2)
// Assert
assertThat(store.getSyncOrNull(WALLET_A, CURSOR_1)).isEqualTo(page1)
assertThat(store.getSyncOrNull(WALLET_A, CURSOR_2)).isEqualTo(page2)
}
@Test
fun `GIVEN entries for two wallets WHEN remove one wallet THEN only that wallet is cleared`() = runTest {
// Arrange
store.store(key = WALLET_A, cursor = CURSOR_1, value = listOf(payment("a")))
store.store(key = WALLET_B, cursor = CURSOR_1, value = listOf(payment("b")))
// Act
store.remove(WALLET_A)
// Assert
assertThat(store.getSyncOrNull(WALLET_A, CURSOR_1)).isNull()
assertThat(store.getSyncOrNull(WALLET_B, CURSOR_1)).isEqualTo(listOf(payment("b")))
}
@Test
fun `GIVEN entries for two wallets WHEN remove both in one call THEN both are cleared`() = runTest {
// Arrange
store.store(key = WALLET_A, cursor = CURSOR_1, value = listOf(payment("a")))
store.store(key = WALLET_B, cursor = CURSOR_1, value = listOf(payment("b")))
// Act
store.remove(listOf(WALLET_A, WALLET_B))
// Assert
assertThat(store.getSyncOrNull(WALLET_A, CURSOR_1)).isNull()
assertThat(store.getSyncOrNull(WALLET_B, CURSOR_1)).isNull()
}
private fun payment(id: String) = TangemPayTxHistoryItem.Payment(
id = id,
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("1.00"),
currency = Currency.getInstance("USD"),
transactionHash = null,
)
private companion object {
const val WALLET_A = "wallet-a"
const val WALLET_B = "wallet-b"
const val CURSOR_1 = "cursor-1"
const val CURSOR_2 = "cursor-2"
const val DATE_MILLIS = 1_700_000_000_000L
}
}

View file

@ -0,0 +1,122 @@
package com.tangem.datasource.local.visa
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemDM
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import org.joda.time.DateTime
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.util.Currency
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TangemPayTxHistoryStoreSerializationTest {
private val json = KotlinxDataStoreSerializer.jsonBuilder { classDiscriminator = "__type" }
private val serializer = MapSerializer(
keySerializer = String.serializer(),
valueSerializer = MapSerializer(
keySerializer = String.serializer(),
valueSerializer = ListSerializer(TangemPayTxHistoryItemDM.serializer()),
),
)
@Test
fun `GIVEN all tx history item types WHEN serialized and deserialized THEN value is preserved`() {
// Arrange
val original: Map<String, Map<String, List<TangemPayTxHistoryItemDM>>> = mapOf(
"wallet-1" to mapOf(
"initial_cursor_key" to listOf(spend(), payment(), fee(), collateral()),
),
)
// Act
val restored = json.decodeFromString(serializer, json.encodeToString(serializer, original))
// Assert
assertThat(restored).isEqualTo(original)
}
@Test
fun `GIVEN Collateral with type field WHEN serialized and deserialized THEN discriminator does not clash`() {
// Arrange
val original: Map<String, Map<String, List<TangemPayTxHistoryItemDM>>> = mapOf(
"wallet-1" to mapOf("cursor" to listOf(collateral())),
)
// Act
val restored = json.decodeFromString(serializer, json.encodeToString(serializer, original))
// Assert
assertThat(restored).isEqualTo(original)
}
@Test
fun `GIVEN tx history item WHEN serialized THEN uses stable SerialName under non-clashing discriminator`() {
// Arrange
val original: Map<String, Map<String, List<TangemPayTxHistoryItemDM>>> = mapOf(
"wallet-1" to mapOf("cursor" to listOf(collateral())),
)
// Act
val encoded = json.encodeToString(serializer, original)
// Assert
assertThat(encoded).contains("\"__type\":\"collateral\"")
}
private fun spend() = TangemPayTxHistoryItemDM.Spend(
id = "spend-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("12.34"),
currency = Currency.getInstance("USD"),
authorizedAmount = BigDecimal("12.34"),
localAmount = BigDecimal("11.00"),
localCurrency = Currency.getInstance("EUR"),
enrichedMerchantName = "Coffee Co",
merchantName = "COFFEE CO",
enrichedMerchantCategory = "Food",
merchantCategoryCode = "5814",
merchantCategory = "restaurants",
status = TangemPayTxHistoryItemDM.Status.COMPLETED,
enrichedMerchantIconUrl = "https://example.com/icon.png",
declinedReason = null,
)
private fun payment() = TangemPayTxHistoryItemDM.Payment(
id = "payment-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("100.00"),
currency = Currency.getInstance("USD"),
transactionHash = "0xabc",
)
private fun fee() = TangemPayTxHistoryItemDM.Fee(
id = "fee-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("0.50"),
currency = Currency.getInstance("USD"),
description = "network fee",
)
private fun collateral() = TangemPayTxHistoryItemDM.Collateral(
id = "collateral-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("250.00"),
currency = Currency.getInstance("USD"),
transactionHash = "0xdef",
type = TangemPayTxHistoryItemDM.Type.Deposit,
)
private companion object {
const val DATE_MILLIS = 1_700_000_000_000L
}
}

View file

@ -0,0 +1,103 @@
package com.tangem.datasource.local.visa.entity
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.test.core.ProvideTestModels
import org.joda.time.DateTime
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource
import java.math.BigDecimal
import java.util.Currency
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TangemPayTxHistoryItemDMConverterTest {
private val toDMConverter = TangemPayTxHistoryItemToDMConverter()
private val toDomainConverter = TangemPayTxHistoryItemToDomainConverter()
@ParameterizedTest
@ProvideTestModels
fun `GIVEN every item subtype WHEN converted to DM and back THEN all fields preserved`(
item: TangemPayTxHistoryItem,
) {
assertRoundTrip(item)
}
@ParameterizedTest
@EnumSource(TangemPayTxHistoryItem.Status::class)
fun `GIVEN every Spend status WHEN converted to DM and back THEN status preserved`(
status: TangemPayTxHistoryItem.Status,
) {
assertRoundTrip(spend().copy(status = status))
}
@ParameterizedTest
@EnumSource(TangemPayTxHistoryItem.Type::class)
fun `GIVEN every Collateral type WHEN converted to DM and back THEN type preserved`(
type: TangemPayTxHistoryItem.Type,
) {
assertRoundTrip(collateral().copy(type = type))
}
private fun assertRoundTrip(item: TangemPayTxHistoryItem) {
// Act
val restored = toDomainConverter.convert(toDMConverter.convert(item))
// Assert
assertThat(restored).isEqualTo(item)
}
private fun provideTestModels() = listOf(spend(), payment(), fee(), collateral())
private fun spend() = TangemPayTxHistoryItem.Spend(
id = "spend-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("12.34"),
currency = Currency.getInstance("USD"),
authorizedAmount = BigDecimal("12.34"),
localAmount = BigDecimal("11.00"),
localCurrency = Currency.getInstance("EUR"),
enrichedMerchantName = "Coffee Co",
merchantName = "COFFEE CO",
enrichedMerchantCategory = "Food",
merchantCategoryCode = "5814",
merchantCategory = "restaurants",
status = TangemPayTxHistoryItem.Status.COMPLETED,
enrichedMerchantIconUrl = "https://example.com/icon.png",
declinedReason = null,
)
private fun payment() = TangemPayTxHistoryItem.Payment(
id = "payment-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("100.00"),
currency = Currency.getInstance("USD"),
transactionHash = "0xabc",
)
private fun fee() = TangemPayTxHistoryItem.Fee(
id = "fee-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("0.50"),
currency = Currency.getInstance("USD"),
description = "network fee",
)
private fun collateral() = TangemPayTxHistoryItem.Collateral(
id = "collateral-1",
jsonRepresentation = "{}",
date = DateTime(DATE_MILLIS),
amount = BigDecimal("250.00"),
currency = Currency.getInstance("USD"),
transactionHash = "0xdef",
type = TangemPayTxHistoryItem.Type.Deposit,
)
private companion object {
const val DATE_MILLIS = 1_700_000_000_000L
}
}