diff --git a/core/datasource/build.gradle.kts b/core/datasource/build.gradle.kts index 34d70a50ec..1bd2b342f5 100644 --- a/core/datasource/build.gradle.kts +++ b/core/datasource/build.gradle.kts @@ -92,6 +92,7 @@ dependencies { // region Room api(deps.room.runtime) + api(deps.room.ktx) ksp(deps.room.compiler) // endregion diff --git a/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json b/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json index 2de6110b6e..42576f0e65 100644 --- a/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json +++ b/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json @@ -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')" ] } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryModule.kt index e2405b39a2..971556c715 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryModule.kt @@ -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() } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/converter/ExpressHistoryConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/converter/ExpressHistoryConverter.kt index e9189cd377..7e7b48acab 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/converter/ExpressHistoryConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/converter/ExpressHistoryConverter.kt @@ -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, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt index 3993988bfe..a771a686ae 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt @@ -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 } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressHistoryDao.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressHistoryDao.kt index 9736bc2b17..e11ee0da8a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressHistoryDao.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressHistoryDao.kt @@ -37,8 +37,8 @@ interface ExpressHistoryDao { fun getCountriesByCode(): Flow> /** - * 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> /** - * 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, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/HistoryIndexDao.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/HistoryIndexDao.kt new file mode 100644 index 0000000000..a05b0b2ea6 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/HistoryIndexDao.kt @@ -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) + + @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, + cursorSortTimeMillis: Long?, + cursorEntityId: String?, + limit: Int, + ): Flow> + + fun observePage(addresses: List, cursor: Cursor?, limit: Int): Flow> = 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, + ) + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/HistoryIndexEntity.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/HistoryIndexEntity.kt new file mode 100644 index 0000000000..d44f16c531 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/HistoryIndexEntity.kt @@ -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"), + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressExchangeEntity.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressExchangeEntity.kt index 9a011acf5b..6eed9b91c6 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressExchangeEntity.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressExchangeEntity.kt @@ -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") diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressOnrampEntity.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressOnrampEntity.kt index 07829651de..ee524c02d4 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressOnrampEntity.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/ExpressOnrampEntity.kt @@ -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, diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/converter/ExpressHistoryConverterTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/converter/ExpressHistoryConverterTest.kt index ca1c5656cd..9b605bde93 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/local/converter/ExpressHistoryConverterTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/converter/ExpressHistoryConverterTest.kt @@ -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" - } } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/txhistory/ExpressHistoryRepository.kt b/data/common/src/main/kotlin/com/tangem/data/common/txhistory/ExpressHistoryRepository.kt index 98fff60938..68ae73ea68 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/txhistory/ExpressHistoryRepository.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/txhistory/ExpressHistoryRepository.kt @@ -9,7 +9,7 @@ import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse */ interface ExpressHistoryRepository { - suspend fun storeExchanges(ownerAddress: String, items: List) + suspend fun storeExchanges(items: List) - suspend fun storeOnramps(ownerAddress: String, items: List) + suspend fun storeOnramps(items: List) } \ No newline at end of file diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt index 9d663279d8..c5bd0a505a 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt @@ -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) diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepository.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepository.kt index 197fb4eaa6..437ef25e42 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepository.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepository.kt @@ -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) { - if (items.isEmpty()) return - val entities = items.map { it.toEntity(ownerAddress) } - expressHistoryDao.upsertExchanges(entities) + override suspend fun storeExchanges(items: List) { + 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) { + override suspend fun storeOnramps(items: List) { 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() }) } diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt index 4035351559..60cf192e2b 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt @@ -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, diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/HistoryIndexConverter.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/HistoryIndexConverter.kt new file mode 100644 index 0000000000..35a87c4704 --- /dev/null +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/HistoryIndexConverter.kt @@ -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 { + 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, +) \ No newline at end of file diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepositoryTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepositoryTest.kt index 2b6fa4523c..94573c7e31 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepositoryTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/DefaultExpressHistoryRepositoryTest.kt @@ -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 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>()) } + 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>()) } coVerify(exactly = 0) { tokenInfoRepository.fetchMissing(any()) } } diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt index c2d160f1b0..00e376fb97 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt @@ -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, diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/HistoryIndexConverterTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/HistoryIndexConverterTest.kt new file mode 100644 index 0000000000..7d83014185 --- /dev/null +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/HistoryIndexConverterTest.kt @@ -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 { + 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 { + 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" + } +} \ No newline at end of file diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/factory/ExpressTransactionAssetFactoryTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/factory/ExpressTransactionAssetFactoryTest.kt index 6cd6041c66..416f8fd250 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/factory/ExpressTransactionAssetFactoryTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/factory/ExpressTransactionAssetFactoryTest.kt @@ -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", diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index fd48b003b1..d0c47c0395 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -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)