Updated on 2026-08-14
This commit is contained in:
parent
6848f91436
commit
b8898092fb
21 changed files with 443 additions and 106 deletions
|
|
@ -92,6 +92,7 @@ dependencies {
|
|||
|
||||
// region Room
|
||||
api(deps.room.runtime)
|
||||
api(deps.room.ktx)
|
||||
ksp(deps.room.compiler)
|
||||
// endregion
|
||||
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue