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

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