Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 13:41:58 +04:00
parent 6848f91436
commit b8898092fb
21 changed files with 443 additions and 106 deletions

View file

@ -92,6 +92,7 @@ dependencies {
// region Room
api(deps.room.runtime)
api(deps.room.ktx)
ksp(deps.room.compiler)
// endregion

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "5d37870ed1a5b37c62e9836dc3fd061c",
"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`)"
}
]
},
@ -606,11 +595,62 @@
"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, '5d37870ed1a5b37c62e9836dc3fd061c')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c0f8e7d958a8852440a5bdec5a989f0e')"
]
}
}

View file

@ -5,6 +5,7 @@ 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
@ -41,5 +42,8 @@ internal interface TxHistoryModule {
@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

@ -4,7 +4,9 @@ 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
@ -21,6 +23,7 @@ import com.tangem.datasource.local.txhistory.db.entity.express.TokenInfoEntity
ExpressSyncStateEntity::class,
OnrampCountryEntity::class,
TokenInfoEntity::class,
HistoryIndexEntity::class,
],
)
abstract class TxHistoryDatabase : RoomDatabase() {
@ -30,4 +33,6 @@ abstract class TxHistoryDatabase : RoomDatabase() {
abstract fun syncStateDao(): ExpressSyncStateDao
abstract fun tokenInfoDao(): TokenInfoDao
abstract fun historyIndexDao(): HistoryIndexDao
}

View file

@ -37,8 +37,8 @@ interface ExpressHistoryDao {
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.
@ -46,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))
@ -54,7 +54,7 @@ interface ExpressHistoryDao {
""",
)
fun observeOutgoingSwaps(
ownerAddress: String,
fromAddress: String,
network: String,
contract: String,
fromCreatedAtIso: String,
@ -63,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(
"""
@ -83,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))
@ -96,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,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

@ -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

@ -9,7 +9,7 @@ import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
*/
interface ExpressHistoryRepository {
suspend fun storeExchanges(ownerAddress: String, items: List<ExchangeItemResponse>)
suspend fun storeExchanges(items: List<ExchangeItemResponse>)
suspend fun storeOnramps(ownerAddress: String, items: List<OnrampItemResponse>)
suspend fun storeOnramps(items: List<OnrampItemResponse>)
}

View file

@ -174,7 +174,7 @@ internal class DefaultOnrampRepository(
.getOrThrow()
if (txHistoryFeatureToggles.isNewTxHistoryEnabled) {
expressHistoryRepository.storeOnramps(ownerAddress = response.payoutAddress, items = listOf(response))
expressHistoryRepository.storeOnramps(items = listOf(response))
}
statusConverter.convert(response)

View file

@ -1,6 +1,9 @@
package com.tangem.data.txhistory.repository
import androidx.room.withTransaction
import com.tangem.data.common.txhistory.ExpressHistoryRepository
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntities
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntity
import com.tangem.data.txhistory.repository.factory.TokenInfoRepository
import com.tangem.data.txhistory.repository.factory.toAssetId
import com.tangem.datasource.api.common.response.getOrThrow
@ -11,8 +14,10 @@ import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaRespon
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.local.converter.toEntity
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.entity.express.ExpressSyncStateEntity
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.wallet.UserWalletId
@ -25,12 +30,15 @@ import javax.inject.Inject
* Fetches express (exchange & onramp) transaction history from the API, persists it into the local database, and
* fetches any missing token metadata for the referenced assets.
*/
@Suppress("LongParameterList")
internal class DefaultExpressHistoryRepository @Inject constructor(
private val exchangeApi: TangemExpressApi,
private val onrampApi: OnrampApi,
private val expressHistoryDao: ExpressHistoryDao,
private val historyIndexDao: HistoryIndexDao,
private val expressSyncStateDao: ExpressSyncStateDao,
private val tokenInfoRepository: TokenInfoRepository,
private val database: TxHistoryDatabase,
private val appScope: AppCoroutineScope,
) : ExpressHistoryRepository {
@ -48,7 +56,7 @@ internal class DefaultExpressHistoryRepository @Inject constructor(
limit = limit,
).getOrThrow()
storeExchanges(ownerAddress = fromAddress, items = response.items)
storeExchanges(items = response.items)
persistHistoryState(
type = ExpressSyncStateEntity.Type.EXCHANGE,
address = fromAddress,
@ -72,7 +80,7 @@ internal class DefaultExpressHistoryRepository @Inject constructor(
limit = limit,
).getOrThrow()
storeExchanges(ownerAddress = fromAddress, items = response.items)
storeExchanges(items = response.items)
persistDeltaState(
type = ExpressSyncStateEntity.Type.EXCHANGE,
address = fromAddress,
@ -95,7 +103,7 @@ internal class DefaultExpressHistoryRepository @Inject constructor(
limit = limit,
).getOrThrow()
storeOnramps(ownerAddress = payoutAddress, items = response.items)
storeOnramps(items = response.items)
persistHistoryState(
type = ExpressSyncStateEntity.Type.ONRAMP,
address = payoutAddress,
@ -119,7 +127,7 @@ internal class DefaultExpressHistoryRepository @Inject constructor(
limit = limit,
).getOrThrow()
storeOnramps(ownerAddress = payoutAddress, items = response.items)
storeOnramps(items = response.items)
persistDeltaState(
type = ExpressSyncStateEntity.Type.ONRAMP,
address = payoutAddress,
@ -128,10 +136,13 @@ internal class DefaultExpressHistoryRepository @Inject constructor(
return response
}
override suspend fun storeExchanges(ownerAddress: String, items: List<ExchangeItemResponse>) {
if (items.isEmpty()) return
val entities = items.map { it.toEntity(ownerAddress) }
expressHistoryDao.upsertExchanges(entities)
override suspend fun storeExchanges(items: List<ExchangeItemResponse>) {
val entities = items.mapNotNull { it.toEntity() }
if (entities.isEmpty()) return
database.withTransaction {
expressHistoryDao.upsertExchanges(entities)
historyIndexDao.upsert(entities.flatMap { it.toHistoryIndexEntities() })
}
fetchMissingTokenInfo(
buildSet {
entities.forEach { entity ->
@ -142,10 +153,13 @@ internal class DefaultExpressHistoryRepository @Inject constructor(
)
}
override suspend fun storeOnramps(ownerAddress: String, items: List<OnrampItemResponse>) {
override suspend fun storeOnramps(items: List<OnrampItemResponse>) {
if (items.isEmpty()) return
val entities = items.map { it.toEntity(ownerAddress) }
expressHistoryDao.upsertOnramps(entities)
val entities = items.map { it.toEntity() }
database.withTransaction {
expressHistoryDao.upsertOnramps(entities)
historyIndexDao.upsert(entities.map { it.toHistoryIndexEntity() })
}
fetchMissingTokenInfo(entities.mapTo(mutableSetOf()) { it.to.toAssetId() })
}

View file

@ -73,7 +73,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
val flow = combine(
flow = expressHistoryDao.observeOutgoingSwaps(
ownerAddress = address,
fromAddress = address,
network = rawNetwork,
contract = contract,
fromCreatedAtIso = fromCreatedAtIso,
@ -86,7 +86,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
activeStatuses = ExpressStatusMapper.activeExchangeStatuses,
).distinctUntilChanged(),
flow3 = expressHistoryDao.observeIncomingOnramps(
ownerAddress = address,
payoutAddress = address,
network = rawNetwork,
contract = contract,
fromCreatedAtIso = fromCreatedAtIso,

View file

@ -0,0 +1,31 @@
package com.tangem.data.txhistory.repository.converter
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import org.joda.time.DateTime
/**
* Index rows for a swap: it shows on the from-token screen (outgoing, under `from_address`) and, when the payout lands
* on another owned token, on that screen too (incoming, under `payout_address`). The set de-duplicates the addresses so
* a swap whose from and payout addresses coincide is indexed once.
*/
internal fun ExpressExchangeEntity.toHistoryIndexEntities(): List<HistoryIndexEntity> {
val sortTimeMillis = DateTime.parse(createdAt).millis
return setOf(fromAddress, payoutAddress).map { address ->
HistoryIndexEntity(
type = HistoryIndexEntity.Type.EXCHANGE.value,
entityId = txId,
address = address,
sortTimeMillis = sortTimeMillis,
)
}
}
/** Onramp is always incoming: a single index row under the payout address. */
internal fun ExpressOnrampEntity.toHistoryIndexEntity(): HistoryIndexEntity = HistoryIndexEntity(
type = HistoryIndexEntity.Type.ONRAMP.value,
entityId = txId,
address = payoutAddress,
sortTimeMillis = DateTime.parse(createdAt).millis,
)

View file

@ -1,6 +1,9 @@
package com.tangem.data.txhistory.repository
import androidx.room.withTransaction
import com.google.common.truth.Truth.assertThat
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntities
import com.tangem.data.txhistory.repository.converter.toHistoryIndexEntity
import com.tangem.data.txhistory.repository.factory.TokenInfoRepository
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
@ -15,8 +18,11 @@ import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaRespon
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.local.converter.toEntity
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.entity.HistoryIndexEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.wallet.UserWalletId
@ -25,8 +31,12 @@ import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@ -37,21 +47,35 @@ internal class DefaultExpressHistoryRepositoryTest {
private val exchangeApi: TangemExpressApi = mockk()
private val onrampApi: OnrampApi = mockk()
private val expressHistoryDao: ExpressHistoryDao = mockk(relaxUnitFun = true)
private val historyIndexDao: HistoryIndexDao = mockk(relaxUnitFun = true)
private val expressSyncStateDao: ExpressSyncStateDao = mockk(relaxUnitFun = true)
private val tokenInfoRepository: TokenInfoRepository = mockk(relaxUnitFun = true)
private val database: TxHistoryDatabase = mockk()
private val repository = DefaultExpressHistoryRepository(
exchangeApi = exchangeApi,
onrampApi = onrampApi,
expressHistoryDao = expressHistoryDao,
historyIndexDao = historyIndexDao,
expressSyncStateDao = expressSyncStateDao,
tokenInfoRepository = tokenInfoRepository,
database = database,
appScope = TestAppCoroutineScope(),
)
@BeforeEach
fun setup() {
clearMocks(exchangeApi, onrampApi, expressHistoryDao, expressSyncStateDao, tokenInfoRepository)
clearMocks(exchangeApi, onrampApi, expressHistoryDao, historyIndexDao, expressSyncStateDao, tokenInfoRepository)
// Run the withTransaction block inline so the DAO writes inside it actually happen and can be verified.
mockkStatic("androidx.room.RoomDatabaseKt")
val block = slot<suspend () -> Any?>()
coEvery { database.withTransaction(capture(block)) } coAnswers { block.captured.invoke() }
}
@AfterEach
fun tearDown() {
unmockkStatic("androidx.room.RoomDatabaseKt")
}
// region exchange history
@ -74,7 +98,7 @@ internal class DefaultExpressHistoryRepositoryTest {
coVerify(exactly = 1) {
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOfNotNull(item.toEntity())) }
}
@Test
@ -130,7 +154,7 @@ internal class DefaultExpressHistoryRepositoryTest {
coVerify(exactly = 1) {
exchangeApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOfNotNull(item.toEntity())) }
}
// endregion
@ -155,7 +179,7 @@ internal class DefaultExpressHistoryRepositoryTest {
coVerify(exactly = 1) {
onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity())) }
}
@Test
@ -176,7 +200,7 @@ internal class DefaultExpressHistoryRepositoryTest {
coVerify(exactly = 1) {
onrampApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity())) }
}
@Test
@ -206,10 +230,11 @@ internal class DefaultExpressHistoryRepositoryTest {
val item = createExchangeItem()
// WHEN
repository.storeExchanges(ownerAddress = ADDRESS, items = listOf(item))
repository.storeExchanges(items = listOf(item))
// THEN
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOfNotNull(item.toEntity())) }
coVerify(exactly = 1) { historyIndexDao.upsert(item.toEntity()!!.toHistoryIndexEntities()) }
coVerify(exactly = 1) {
tokenInfoRepository.fetchMissing(
setOf(
@ -226,10 +251,11 @@ internal class DefaultExpressHistoryRepositoryTest {
val item = createOnrampItem()
// WHEN
repository.storeOnramps(ownerAddress = ADDRESS, items = listOf(item))
repository.storeOnramps(items = listOf(item))
// THEN
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity())) }
coVerify(exactly = 1) { historyIndexDao.upsert(listOf(item.toEntity().toHistoryIndexEntity())) }
coVerify(exactly = 1) {
tokenInfoRepository.fetchMissing(setOf(ExpressAsset.ID(networkId = "bitcoin", contractAddress = "0xtoContract")))
}
@ -238,12 +264,27 @@ internal class DefaultExpressHistoryRepositoryTest {
@Test
fun `GIVEN empty items WHEN store THEN does nothing`() = runTest {
// WHEN
repository.storeExchanges(ownerAddress = ADDRESS, items = emptyList())
repository.storeOnramps(ownerAddress = ADDRESS, items = emptyList())
repository.storeExchanges(items = emptyList())
repository.storeOnramps(items = emptyList())
// THEN
coVerify(exactly = 0) { expressHistoryDao.upsertExchanges(any()) }
coVerify(exactly = 0) { expressHistoryDao.upsertOnramps(any()) }
coVerify(exactly = 0) { historyIndexDao.upsert(any<List<HistoryIndexEntity>>()) }
coVerify(exactly = 0) { tokenInfoRepository.fetchMissing(any()) }
}
@Test
fun `GIVEN exchange with null fromAddress WHEN storeExchanges THEN it is skipped`() = runTest {
// GIVEN
val item = createExchangeItem().copy(fromAddress = null)
// WHEN
repository.storeExchanges(items = listOf(item))
// THEN
coVerify(exactly = 0) { expressHistoryDao.upsertExchanges(any()) }
coVerify(exactly = 0) { historyIndexDao.upsert(any<List<HistoryIndexEntity>>()) }
coVerify(exactly = 0) { tokenInfoRepository.fetchMissing(any()) }
}

View file

@ -89,7 +89,6 @@ internal class ExpressTxHistoryConverterTest {
toActualAmount: String? = null,
) = ExpressExchangeEntity(
txId = "tx-1",
ownerAddress = "owner",
providerId = "provider",
fromAddress = "owner",
payinAddress = "payin-addr",
@ -131,7 +130,6 @@ internal class ExpressTxHistoryConverterTest {
status: String = "finished",
) = ExpressOnrampEntity(
txId = "onramp-1",
ownerAddress = "owner",
providerId = "provider",
payoutAddress = "owner",
status = status,

View file

@ -0,0 +1,97 @@
package com.tangem.data.txhistory.repository.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.txhistory.db.entity.HistoryIndexEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import io.mockk.every
import io.mockk.mockk
import org.joda.time.DateTime
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Verifies the indexing rules that build [HistoryIndexEntity] rows: a swap is indexed under both its from- and
* into the sort time.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class HistoryIndexConverterTest {
@Test
fun `GIVEN swap with distinct from and payout WHEN indexed THEN one row per address`() {
// Arrange
val entity = exchange(txId = "tx1", from = "addrA", payout = "addrB", createdAt = CREATED_AT)
// Act
val rows = entity.toHistoryIndexEntities()
// Assert
assertThat(rows).containsExactly(
indexRow(HistoryIndexEntity.Type.EXCHANGE, "tx1", "addrA"),
indexRow(HistoryIndexEntity.Type.EXCHANGE, "tx1", "addrB"),
)
}
@Test
fun `GIVEN swap with equal from and payout WHEN indexed THEN de-duplicated to one row`() {
// Arrange
val entity = exchange(txId = "tx1", from = "same", payout = "same", createdAt = CREATED_AT)
// Act
val rows = entity.toHistoryIndexEntities()
// Assert
assertThat(rows).containsExactly(indexRow(HistoryIndexEntity.Type.EXCHANGE, "tx1", "same"))
}
@Test
fun `GIVEN onramp WHEN indexed THEN single row under payout address`() {
// Arrange
val entity = onramp(txId = "tx2", payout = "addrP", createdAt = CREATED_AT)
// Act
val row = entity.toHistoryIndexEntity()
// Assert
assertThat(row).isEqualTo(indexRow(HistoryIndexEntity.Type.ONRAMP, "tx2", "addrP"))
}
@Test
fun `GIVEN ISO-8601 created_at WHEN indexed THEN parsed into sort time millis`() {
// Arrange
val entity = onramp(txId = "tx2", payout = "addrP", createdAt = "2026-07-03T10:00:00.000Z")
// Act
val row = entity.toHistoryIndexEntity()
// Assert
assertThat(row.sortTimeMillis).isEqualTo(DateTime.parse("2026-07-03T10:00:00.000Z").millis)
}
private fun exchange(txId: String, from: String, payout: String, createdAt: String) =
mockk<ExpressExchangeEntity> {
every { this@mockk.txId } returns txId
every { fromAddress } returns from
every { payoutAddress } returns payout
every { this@mockk.createdAt } returns createdAt
}
private fun onramp(txId: String, payout: String, createdAt: String) =
mockk<ExpressOnrampEntity> {
every { this@mockk.txId } returns txId
every { payoutAddress } returns payout
every { this@mockk.createdAt } returns createdAt
}
private fun indexRow(type: HistoryIndexEntity.Type, txId: String, address: String) = HistoryIndexEntity(
type = type.value,
entityId = txId,
address = address,
sortTimeMillis = DateTime.parse(CREATED_AT).millis,
)
private companion object {
const val CREATED_AT = "2026-07-03T10:00:00.000Z"
}
}

View file

@ -88,7 +88,6 @@ internal class ExpressTransactionAssetFactoryTest {
/** A swap from a native coin (empty contract) to an Ethereum token. */
private fun coinToTokenSwap() = ExpressExchangeEntity(
txId = "tx-1",
ownerAddress = "owner",
providerId = "provider",
fromAddress = "owner",
payinAddress = "payin-addr",

View file

@ -157,10 +157,7 @@ internal class DefaultSwapRepository(
.getOrThrow()
if (txHistoryFeatureToggles.isNewTxHistoryEnabled) {
expressHistoryRepository.storeExchanges(
ownerAddress = response.fromAddress.orEmpty(),
items = listOf(response),
)
expressHistoryRepository.storeExchanges(items = listOf(response))
}
exchangeStatusConverter.convert(response)