Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-04 14:14:44 +04:00
parent 7c9112fa70
commit 4390b59231
25 changed files with 1810 additions and 570 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "aafa8b51b5a5a32d0ec2b0720cec6c1e",
"identityHash": "942246bf975439606ad20e05b930827c",
"entities": [
{
"tableName": "express_provider",
@ -44,7 +44,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, `status` TEXT NOT NULL, `to_is_actual` INTEGER NOT NULL DEFAULT 0, `payin_hash` TEXT, `payout_hash` TEXT, `external_tx_id` TEXT, `external_tx_url` TEXT, `rate_type` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `from_network` TEXT NOT NULL, `from_token_id` TEXT, `from_raw_amount` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `to_network` TEXT NOT NULL, `to_token_id` TEXT, `to_raw_amount` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `refund_network` TEXT, `refund_token_id` TEXT, `refund_raw_amount` TEXT, `refund_decimals` INTEGER, `refund_hash` TEXT, PRIMARY KEY(`tx_id`), FOREIGN KEY(`provider_id`) REFERENCES `express_provider`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )",
"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 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_status` TEXT, `external_tx_url` TEXT, `payin_hash` TEXT, `payout_hash` TEXT, `refund_network` TEXT, `refund_contract_address` TEXT, `created_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`), FOREIGN KEY(`provider_id`) REFERENCES `express_provider`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )",
"fields": [
{
"fieldPath": "txId",
@ -64,6 +64,48 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fromAddress",
"columnName": "from_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payinAddress",
"columnName": "payin_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payinExtraId",
"columnName": "payin_extra_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "payoutAddress",
"columnName": "payout_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "refundAddress",
"columnName": "refund_address",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "refundExtraId",
"columnName": "refund_extra_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rateType",
"columnName": "rate_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
@ -71,11 +113,22 @@
"notNull": true
},
{
"fieldPath": "toIsActual",
"columnName": "to_is_actual",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
"fieldPath": "externalTxId",
"columnName": "external_tx_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "externalTxStatus",
"columnName": "external_tx_status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "externalTxUrl",
"columnName": "external_tx_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "payinHash",
@ -90,33 +143,39 @@
"notNull": false
},
{
"fieldPath": "externalTxId",
"columnName": "external_tx_id",
"fieldPath": "refundNetwork",
"columnName": "refund_network",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "externalTxUrl",
"columnName": "external_tx_url",
"fieldPath": "refundContractAddress",
"columnName": "refund_contract_address",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rateType",
"columnName": "rate_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"fieldPath": "payTill",
"columnName": "pay_till",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "averageDuration",
"columnName": "average_duration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "from.contractAddress",
"columnName": "from_contract_address",
"affinity": "TEXT",
"notNull": true
},
{
@ -125,18 +184,6 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "from.tokenId",
"columnName": "from_token_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "from.rawAmount",
"columnName": "from_raw_amount",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "from.decimals",
"columnName": "from_decimals",
@ -144,20 +191,26 @@
"notNull": true
},
{
"fieldPath": "to.network",
"columnName": "to_network",
"fieldPath": "from.amount",
"columnName": "from_amount",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "to.tokenId",
"columnName": "to_token_id",
"fieldPath": "from.actualAmount",
"columnName": "from_actual_amount",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "to.rawAmount",
"columnName": "to_raw_amount",
"fieldPath": "to.contractAddress",
"columnName": "to_contract_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "to.network",
"columnName": "to_network",
"affinity": "TEXT",
"notNull": true
},
@ -168,32 +221,14 @@
"notNull": true
},
{
"fieldPath": "refund.network",
"columnName": "refund_network",
"fieldPath": "to.amount",
"columnName": "to_amount",
"affinity": "TEXT",
"notNull": false
"notNull": true
},
{
"fieldPath": "refund.tokenId",
"columnName": "refund_token_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "refund.rawAmount",
"columnName": "refund_raw_amount",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "refund.decimals",
"columnName": "refund_decimals",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "refund.hash",
"columnName": "refund_hash",
"fieldPath": "to.actualAmount",
"columnName": "to_actual_amount",
"affinity": "TEXT",
"notNull": false
}
@ -206,15 +241,15 @@
},
"indices": [
{
"name": "index_express_exchange_owner_address_from_network_updated_at",
"name": "index_express_exchange_owner_address_from_network_created_at",
"unique": false,
"columnNames": [
"owner_address",
"from_network",
"updated_at"
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_from_network_updated_at` ON `${TABLE_NAME}` (`owner_address`, `from_network`, `updated_at`)"
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_from_network_created_at` ON `${TABLE_NAME}` (`owner_address`, `from_network`, `created_at`)"
},
{
"name": "index_express_exchange_owner_address_payin_hash",
@ -235,16 +270,6 @@
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_payout_hash` ON `${TABLE_NAME}` (`owner_address`, `payout_hash`)"
},
{
"name": "index_express_exchange_owner_address_refund_hash",
"unique": false,
"columnNames": [
"owner_address",
"refund_hash"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_refund_hash` ON `${TABLE_NAME}` (`owner_address`, `refund_hash`)"
}
],
"foreignKeys": [
@ -263,7 +288,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, `status` TEXT NOT NULL, `from_currency_code` TEXT NOT NULL, `from_amount` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_token_id` TEXT, `to_expected_raw_amount` TEXT NOT NULL, `to_actual_raw_amount` TEXT, `to_decimals` INTEGER NOT NULL, `payout_hash` TEXT, `external_tx_id` TEXT, `external_tx_url` TEXT, `rate_type` TEXT NOT NULL, `fail_reason` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `refund_currency_code` TEXT, `refund_amount` TEXT, PRIMARY KEY(`tx_id`), FOREIGN KEY(`provider_id`) REFERENCES `express_provider`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )",
"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 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_status` TEXT, `external_tx_url` TEXT, `payin_hash` TEXT, `payout_hash` TEXT, `refund_network` TEXT, `refund_contract_address` TEXT, `created_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`), FOREIGN KEY(`provider_id`) REFERENCES `express_provider`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )",
"fields": [
{
"fieldPath": "txId",
@ -284,68 +309,38 @@
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"fieldPath": "fromAddress",
"columnName": "from_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fromCurrencyCode",
"columnName": "from_currency_code",
"fieldPath": "payinAddress",
"columnName": "payin_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fromAmount",
"columnName": "from_amount",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toNetwork",
"columnName": "to_network",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toTokenId",
"columnName": "to_token_id",
"fieldPath": "payinExtraId",
"columnName": "payin_extra_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "toExpectedRawAmount",
"columnName": "to_expected_raw_amount",
"fieldPath": "payoutAddress",
"columnName": "payout_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toActualRawAmount",
"columnName": "to_actual_raw_amount",
"fieldPath": "refundAddress",
"columnName": "refund_address",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "toDecimals",
"columnName": "to_decimals",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "payoutHash",
"columnName": "payout_hash",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "externalTxId",
"columnName": "external_tx_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "externalTxUrl",
"columnName": "external_tx_url",
"fieldPath": "refundExtraId",
"columnName": "refund_extra_id",
"affinity": "TEXT",
"notNull": false
},
@ -356,32 +351,128 @@
"notNull": true
},
{
"fieldPath": "failReason",
"columnName": "fail_reason",
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "externalTxId",
"columnName": "external_tx_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "externalTxStatus",
"columnName": "external_tx_status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "externalTxUrl",
"columnName": "external_tx_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "payinHash",
"columnName": "payin_hash",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "payoutHash",
"columnName": "payout_hash",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "refundNetwork",
"columnName": "refund_network",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "refundContractAddress",
"columnName": "refund_contract_address",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "refund.currencyCode",
"columnName": "refund_currency_code",
"fieldPath": "payTill",
"columnName": "pay_till",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "refund.amount",
"columnName": "refund_amount",
"fieldPath": "averageDuration",
"columnName": "average_duration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "from.contractAddress",
"columnName": "from_contract_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "from.network",
"columnName": "from_network",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "from.decimals",
"columnName": "from_decimals",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "from.amount",
"columnName": "from_amount",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "from.actualAmount",
"columnName": "from_actual_amount",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "to.contractAddress",
"columnName": "to_contract_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "to.network",
"columnName": "to_network",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "to.decimals",
"columnName": "to_decimals",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "to.amount",
"columnName": "to_amount",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "to.actualAmount",
"columnName": "to_actual_amount",
"affinity": "TEXT",
"notNull": false
}
@ -394,15 +485,15 @@
},
"indices": [
{
"name": "index_express_onramp_owner_address_to_network_updated_at",
"name": "index_express_onramp_owner_address_to_network_created_at",
"unique": false,
"columnNames": [
"owner_address",
"to_network",
"updated_at"
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_owner_address_to_network_updated_at` ON `${TABLE_NAME}` (`owner_address`, `to_network`, `updated_at`)"
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_owner_address_to_network_created_at` ON `${TABLE_NAME}` (`owner_address`, `to_network`, `created_at`)"
},
{
"name": "index_express_onramp_owner_address_payout_hash",
@ -428,12 +519,57 @@
]
}
]
},
{
"tableName": "express_sync_state",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `address` TEXT NOT NULL, `is_initial_completed` INTEGER NOT NULL, `after_cursor` TEXT, `delta_cursor` TEXT, PRIMARY KEY(`type`, `address`))",
"fields": [
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isInitialCompleted",
"columnName": "is_initial_completed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "afterCursor",
"columnName": "after_cursor",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "deltaCursor",
"columnName": "delta_cursor",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"type",
"address"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"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, 'aafa8b51b5a5a32d0ec2b0720cec6c1e')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '942246bf975439606ad20e05b930827c')"
]
}
}

View file

@ -87,10 +87,17 @@ interface TangemExpressApi {
@Body body: ExchangeSentRequestBody,
): ApiResponse<ExchangeSentResponseBody>
@GET("exchange/history")
@GET("history/exchange")
suspend fun getHistory(
@Query("wallet_address") walletAddress: String,
@Query("cursor") cursor: String?,
@Query("fromAddress") fromAddress: String,
@Query("afterCursor") cursor: String?,
@Query("limit") limit: Int = 100,
): ApiResponse<ExchangeHistoryResponse>
@GET("history/delta/exchange")
suspend fun getHistoryDelta(
@Query("fromAddress") fromAddress: String,
@Query("beforeCursor") cursor: String?,
@Query("limit") limit: Int = 100,
): ApiResponse<ExchangeHistoryDeltaResponse>
}

View file

@ -5,81 +5,16 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ExchangeHistoryResponse(
@Json(name = "data")
val data: List<ExchangeRecord>,
@Json(name = "next_cursor")
val nextCursor: String,
@Json(name = "has_more")
val hasMore: Boolean,
) {
@Json(name = "items")
val items: List<ExchangeItemResponse>,
@Json(name = "pagination")
val pagination: ExpressPagination,
)
@JsonClass(generateAdapter = true)
data class ExchangeRecord(
@Json(name = "tx_id")
val txId: String,
@Json(name = "status")
val status: String,
@Json(name = "provider")
val provider: Provider,
@Json(name = "from")
val from: AssetRef,
@Json(name = "to")
val to: AssetRef,
@Json(name = "payin_hash")
val payinHash: String?,
@Json(name = "payout_hash")
val payoutHash: String?,
@Json(name = "external_tx_id")
val externalTxId: String?,
@Json(name = "external_tx_url")
val externalTxUrl: String?,
@Json(name = "refund")
val refund: RefundInfo?,
@Json(name = "rate_type")
val rateType: String,
@Json(name = "created_at")
val createdAt: Long,
@Json(name = "updated_at")
val updatedAt: Long,
)
@JsonClass(generateAdapter = true)
data class Provider(
@Json(name = "id")
val id: String,
@Json(name = "name")
val name: String,
@Json(name = "icon_url")
val iconUrl: String,
@Json(name = "provider_url")
val providerUrl: String,
)
@JsonClass(generateAdapter = true)
data class AssetRef(
@Json(name = "network")
val network: String,
@Json(name = "token_id")
val tokenId: String?,
@Json(name = "raw_amount")
val rawAmount: String,
@Json(name = "decimals")
val decimals: Int,
@Json(name = "is_actual")
val isActual: Boolean?,
)
@JsonClass(generateAdapter = true)
data class RefundInfo(
@Json(name = "network")
val network: String,
@Json(name = "token_id")
val tokenId: String?,
@Json(name = "raw_amount")
val rawAmount: String,
@Json(name = "decimals")
val decimals: Int,
@Json(name = "hash")
val hash: String?,
)
}
@JsonClass(generateAdapter = true)
data class ExchangeHistoryDeltaResponse(
@Json(name = "items")
val items: List<ExchangeItemResponse>,
@Json(name = "pagination")
val pagination: ExpressPaginationDelta,
)

View file

@ -0,0 +1,156 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ExchangeItemResponse(
// region transaction info
@Json(name = "txId")
val txId: String,
@Json(name = "providerId")
val providerId: String,
/** Address from which the source assets were sent */
@Json(name = "fromAddress")
val fromAddress: String,
/** Address to which the source assets were transferred for the exchange */
@Json(name = "payinAddress")
val payinAddress: String,
/** Extra ID used for the pay-in transaction */
@Json(name = "payinExtraId")
val payinExtraId: String?,
/** Address that received the target assets */
@Json(name = "payoutAddress")
val payoutAddress: String,
/** Refund destination address */
@Json(name = "refundAddress")
val refundAddress: String?,
/** Extra ID used for refunds */
@Json(name = "refundExtraId")
val refundExtraId: String?,
/** Exchange rate type (e.g. float, fixed) */
@Json(name = "rateType")
val rateType: String,
@Json(name = "status")
val status: Status,
/** External transaction ID (CEX only) */
@Json(name = "externalTxId")
val externalTxId: String?,
/** Transaction status reported by the provider */
@Json(name = "externalTxStatus")
val externalTxStatus: String?,
/** URL to view the transaction details (CEX only) */
@Json(name = "externalTxUrl")
val externalTxUrl: String?,
/** Blockchain hash of the pay-in transaction */
@Json(name = "payinHash")
val payinHash: String?,
/** Blockchain hash of the payout transaction */
@Json(name = "payoutHash")
val payoutHash: String?,
/** Network used for the refund transaction */
@Json(name = "refundNetwork")
val refundNetwork: String?,
/** Refunded token contract address */
@Json(name = "refundContractAddress")
val refundContractAddress: String?,
/** Transaction creation timestamp in ISO-8601 format */
@Json(name = "createdAt")
val createdAt: String,
/** Pay-in expiration timestamp in ISO-8601 format */
@Json(name = "payTill")
val payTill: String?,
/** Average provider exchange duration in seconds */
@Json(name = "averageDuration")
val averageDuration: Long?,
// endregion
// region fromAsset info
@Json(name = "fromContractAddress")
val fromContractAddress: String,
@Json(name = "fromNetwork")
val fromNetwork: String,
@Json(name = "fromDecimals")
val fromDecimals: Int,
@Json(name = "fromAmount")
val fromAmount: String,
// endregion
// region toAsset info
@Json(name = "toContractAddress")
val toContractAddress: String,
@Json(name = "toNetwork")
val toNetwork: String,
@Json(name = "toDecimals")
val toDecimals: Int,
@Json(name = "toAmount")
val toAmount: String,
@Json(name = "toActualAmount")
val toActualAmount: String?,
// endregion
) {
enum class Status {
@Json(name = "unknown")
UNKNOWN,
@Json(name = "exchange-tx-sent")
EXCHANGE_TX_SENT,
@Json(name = "waiting")
WAITING,
@Json(name = "waiting-tx-hash")
WAITING_TX_HASH,
@Json(name = "expired")
EXPIRED,
@Json(name = "confirming")
CONFIRMING,
@Json(name = "exchanging")
EXCHANGING,
@Json(name = "sending")
SENDING,
@Json(name = "finished")
FINISHED,
@Json(name = "failed")
FAILED,
@Json(name = "tx-failed")
TX_FAILED,
@Json(name = "refunded")
REFUNDED,
@Json(name = "verifying")
VERIFYING,
@Json(name = "paused")
PAUSED,
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ExpressPagination(
@Json(name = "endCursor")
val endCursor: String?,
@Json(name = "startDeltaCursor")
val startDeltaCursor: String?,
@Json(name = "hasMore")
val hasMore: Boolean,
)
@JsonClass(generateAdapter = true)
data class ExpressPaginationDelta(
@Json(name = "startCursor")
val startCursor: String?,
@Json(name = "hasMore")
val hasMore: Boolean,
)

View file

@ -3,6 +3,7 @@ package com.tangem.datasource.api.onramp
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.onramp.models.request.OnrampPairsRequest
import com.tangem.datasource.api.onramp.models.response.OnrampDataResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampQuoteResponse
import com.tangem.datasource.api.onramp.models.response.OnrampStatusResponse
@ -88,10 +89,17 @@ interface OnrampApi {
@Query("txId") txId: String,
): ApiResponse<OnrampStatusResponse>
@GET("onramp/history")
@GET("history/onramp")
suspend fun getHistory(
@Query("wallet_address") walletAddress: String,
@Query("cursor") cursor: String?,
@Query("payoutAddress") payoutAddress: String,
@Query("afterCursor") afterCursor: String?,
@Query("limit") limit: Int = 100,
): ApiResponse<OnrampHistoryResponse>
@GET("history/delta/onramp")
suspend fun getHistoryDelta(
@Query("payoutAddress") payoutAddress: String,
@Query("beforeCursor") cursor: String?,
@Query("limit") limit: Int = 100,
): ApiResponse<OnrampHistoryDeltaResponse>
}

View file

@ -2,86 +2,21 @@ package com.tangem.datasource.api.onramp.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.express.models.response.ExpressPagination
import com.tangem.datasource.api.express.models.response.ExpressPaginationDelta
@JsonClass(generateAdapter = true)
data class OnrampHistoryResponse(
@Json(name = "data")
val data: List<OnrampRecord>,
@Json(name = "next_cursor")
val nextCursor: String,
@Json(name = "has_more")
val hasMore: Boolean,
) {
@Json(name = "items")
val items: List<OnrampItemResponse>,
@Json(name = "pagination")
val pagination: ExpressPagination,
)
@JsonClass(generateAdapter = true)
data class OnrampRecord(
@Json(name = "tx_id")
val txId: String,
@Json(name = "status")
val status: String,
@Json(name = "provider")
val provider: Provider,
@Json(name = "from")
val from: FiatRef,
@Json(name = "to")
val to: OnrampAssetRef,
@Json(name = "payout_hash")
val payoutHash: String?,
@Json(name = "external_tx_id")
val externalTxId: String?,
@Json(name = "external_tx_url")
val externalTxUrl: String?,
@Json(name = "refund")
val refund: OnrampRefundInfo?,
@Json(name = "rate_type")
val rateType: String,
@Json(name = "fail_reason")
val failReason: String?,
@Json(name = "created_at")
val createdAt: Long,
@Json(name = "updated_at")
val updatedAt: Long,
)
@JsonClass(generateAdapter = true)
data class Provider(
@Json(name = "id")
val id: String,
@Json(name = "name")
val name: String,
@Json(name = "icon_url")
val iconUrl: String,
@Json(name = "provider_url")
val providerUrl: String,
)
@JsonClass(generateAdapter = true)
data class FiatRef(
@Json(name = "currency_code")
val currencyCode: String,
@Json(name = "amount")
val amount: String,
)
@JsonClass(generateAdapter = true)
data class OnrampAssetRef(
@Json(name = "network")
val network: String,
@Json(name = "token_id")
val tokenId: String?,
@Json(name = "expected_raw_amount")
val expectedRawAmount: String,
@Json(name = "actual_raw_amount")
val actualRawAmount: String?,
@Json(name = "decimals")
val decimals: Int,
)
@JsonClass(generateAdapter = true)
data class OnrampRefundInfo(
@Json(name = "currency_code")
val currencyCode: String,
@Json(name = "amount")
val amount: String,
)
}
@JsonClass(generateAdapter = true)
data class OnrampHistoryDeltaResponse(
@Json(name = "items")
val items: List<OnrampItemResponse>,
@Json(name = "pagination")
val pagination: ExpressPaginationDelta,
)

View file

@ -0,0 +1,156 @@
package com.tangem.datasource.api.onramp.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class OnrampItemResponse(
// region transaction info
@Json(name = "txId")
val txId: String,
@Json(name = "providerId")
val providerId: String,
/** Address from which the source assets were taken for the exchange */
@Json(name = "fromAddress")
val fromAddress: String,
/** Address to which the assets were transferred for the exchange */
@Json(name = "payinAddress")
val payinAddress: String,
/** Extra ID used for the pay-in transaction */
@Json(name = "payinExtraId")
val payinExtraId: String?,
/** Address that received the target assets */
@Json(name = "payoutAddress")
val payoutAddress: String,
/** Refund destination address */
@Json(name = "refundAddress")
val refundAddress: String?,
/** Extra ID used for refunds */
@Json(name = "refundExtraId")
val refundExtraId: String?,
/** Exchange rate type used in the transaction (float, fixed) */
@Json(name = "rateType")
val rateType: String,
@Json(name = "status")
val status: Status,
/** External transaction ID (CEX only) */
@Json(name = "externalTxId")
val externalTxId: String?,
/** Transaction status reported by the provider */
@Json(name = "externalTxStatus")
val externalTxStatus: String?,
/** URL to view the transaction details (CEX only) */
@Json(name = "externalTxUrl")
val externalTxUrl: String?,
/** Blockchain hash of the pay-in transaction */
@Json(name = "payinHash")
val payinHash: String?,
/** Blockchain hash of the payout transaction */
@Json(name = "payoutHash")
val payoutHash: String?,
/** Network used for the refund transaction (when status is refunded) */
@Json(name = "refundNetwork")
val refundNetwork: String?,
/** Refunded token contract address */
@Json(name = "refundContractAddress")
val refundContractAddress: String?,
/** Transaction creation timestamp in ISO-8601 format */
@Json(name = "createdAt")
val createdAt: String,
/** Pay-in expiration timestamp in ISO-8601 format */
@Json(name = "payTill")
val payTill: String?,
/** Average provider exchange duration in seconds */
@Json(name = "averageDuration")
val averageDuration: Long?,
// endregion
// region fromAsset info
@Json(name = "fromContractAddress")
val fromContractAddress: String,
@Json(name = "fromNetwork")
val fromNetwork: String,
@Json(name = "fromDecimals")
val fromDecimals: Int,
@Json(name = "fromAmount")
val fromAmount: String,
// endregion
// region toAsset info
@Json(name = "toContractAddress")
val toContractAddress: String,
@Json(name = "toNetwork")
val toNetwork: String,
@Json(name = "toDecimals")
val toDecimals: Int,
@Json(name = "toAmount")
val toAmount: String,
@Json(name = "toActualAmount")
val toActualAmount: String?,
// endregion
) {
enum class Status {
@Json(name = "unknown")
UNKNOWN,
@Json(name = "exchange-tx-sent")
EXCHANGE_TX_SENT,
@Json(name = "waiting")
WAITING,
@Json(name = "waiting-tx-hash")
WAITING_TX_HASH,
@Json(name = "expired")
EXPIRED,
@Json(name = "confirming")
CONFIRMING,
@Json(name = "exchanging")
EXCHANGING,
@Json(name = "sending")
SENDING,
@Json(name = "finished")
FINISHED,
@Json(name = "failed")
FAILED,
@Json(name = "tx-failed")
TX_FAILED,
@Json(name = "refunded")
REFUNDED,
@Json(name = "verifying")
VERIFYING,
@Json(name = "paused")
PAUSED,
}
}

View file

@ -1,23 +1,15 @@
package com.tangem.datasource.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import androidx.room.Room
import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase
import com.tangem.datasource.local.txhistory.store.CommonSyncState
import com.tangem.datasource.local.txhistory.store.CommonSyncStateKey
import com.tangem.datasource.local.txhistory.store.DefaultTxHistoryStore
import com.tangem.datasource.local.txhistory.store.TxHistoryStore
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
import com.tangem.datasource.utils.KotlinxDataStoreSerializer.Companion.jsonBuilder
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.builtins.MapSerializer
import javax.inject.Singleton
@Module
@ -35,38 +27,15 @@ internal interface TxHistoryModule {
context = context,
klass = TxHistoryDatabase::class.java,
name = TX_HISTORY_DATABASE_NAME,
).build()
)
.fallbackToDestructiveMigration(true)
.build()
}
@Provides
@Singleton
fun provideTxHistoryStore(@ApplicationContext context: Context, appScope: AppCoroutineScope): TxHistoryStore {
val commonSerializer = KotlinxDataStoreSerializer(
defaultValue = emptyMap(),
serializer = MapSerializer(
CommonSyncStateKey.serializer(),
CommonSyncState.serializer(),
),
json = jsonBuilder {
allowStructuredMapKeys = true
},
)
fun provideExpressHistoryDao(database: TxHistoryDatabase): ExpressHistoryDao = database.expressHistoryDao()
val expressExchangeStore = DataStoreFactory.create(
serializer = commonSerializer,
produceFile = { context.dataStoreFile(fileName = "TxHistoryExpressExchangeStore") },
scope = appScope,
)
val expressOnrampStore = DataStoreFactory.create(
serializer = commonSerializer,
produceFile = { context.dataStoreFile(fileName = "TxHistoryExpressOnrampStore") },
scope = appScope,
)
return DefaultTxHistoryStore(
expressExchangeStore = expressExchangeStore,
expressOnrampStore = expressOnrampStore,
)
}
@Provides
fun provideSyncStateDao(database: TxHistoryDatabase): ExpressSyncStateDao = database.syncStateDao()
}
}

View file

@ -2,7 +2,9 @@ package com.tangem.datasource.local.txhistory.db
import androidx.room.Database
import androidx.room.RoomDatabase
import com.tangem.datasource.local.txhistory.db.entity.ExpressHistoryDao
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.entity.express.ExpressSyncStateEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
@ -13,9 +15,12 @@ import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEn
ExpressProviderEntity::class,
ExpressExchangeEntity::class,
ExpressOnrampEntity::class,
ExpressSyncStateEntity::class,
],
)
abstract class TxHistoryDatabase : RoomDatabase() {
abstract fun expressHistoryDao(): ExpressHistoryDao
abstract fun syncStateDao(): ExpressSyncStateDao
}

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.local.txhistory.db.entity
package com.tangem.datasource.local.txhistory.db.dao
import androidx.room.Dao
import androidx.room.Insert
@ -26,7 +26,7 @@ interface ExpressHistoryDao {
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
ORDER BY updated_at DESC
ORDER BY created_at DESC
""",
)
fun observeExchanges(ownerAddress: String): Flow<List<ExpressExchangeEntity>>
@ -36,7 +36,7 @@ interface ExpressHistoryDao {
SELECT *
FROM express_onramp
WHERE owner_address = :ownerAddress
ORDER BY updated_at DESC
ORDER BY created_at DESC
""",
)
fun observeOnramps(ownerAddress: String): Flow<List<ExpressOnrampEntity>>
@ -63,17 +63,6 @@ interface ExpressHistoryDao {
)
suspend fun findExchangeByPayoutHash(ownerAddress: String, hash: String): ExpressExchangeEntity?
@Query(
"""
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
AND refund_hash = :hash
LIMIT 1
""",
)
suspend fun findExchangeByRefundHash(ownerAddress: String, hash: String): ExpressExchangeEntity?
@Query(
"""
SELECT *

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.local.txhistory.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ExpressSyncStateDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(item: ExpressSyncStateEntity)
@Query(
"""
SELECT *
FROM express_sync_state
WHERE type = :type
AND address = :address
LIMIT 1
""",
)
fun observe(type: String, address: String): Flow<ExpressSyncStateEntity?>
}

View file

@ -2,7 +2,11 @@ package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.*
@Suppress("BooleanPropertyNaming")
/**
* Persisted representation of a single exchange transaction.
*
* Mirrors [com.tangem.datasource.api.express.models.response.ExchangeItemResponse].
*/
@Entity(
tableName = "express_exchange",
foreignKeys = [
@ -14,10 +18,9 @@ import androidx.room.*
),
],
indices = [
Index(value = ["owner_address", "from_network", "updated_at"]),
Index(value = ["owner_address", "from_network", "created_at"]),
Index(value = ["owner_address", "payin_hash"]),
Index(value = ["owner_address", "payout_hash"]),
Index(value = ["owner_address", "refund_hash"]),
],
)
data class ExpressExchangeEntity(
@ -26,55 +29,38 @@ 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,
/**
* waiting
* confirming
* exchanging
* sending
* finished
* failed
* refunded
* expired
*/
@ColumnInfo(name = "status")
val status: String,
/** Address from which the source assets were sent */
@ColumnInfo(name = "from_address")
val fromAddress: String,
@Embedded(prefix = "from_")
val from: AssetEmbedded,
/** Address to which the source assets were transferred for the exchange */
@ColumnInfo(name = "payin_address")
val payinAddress: String,
@Embedded(prefix = "to_")
val to: AssetEmbedded,
/** Extra ID used for the pay-in transaction */
@ColumnInfo(name = "payin_extra_id")
val payinExtraId: String?,
/**
* true -> actual provider-confirmed amount
* false -> estimated amount
*/
@ColumnInfo(name = "to_is_actual", defaultValue = "0")
val toIsActual: Boolean,
/** Address that received the target assets */
@ColumnInfo(name = "payout_address")
val payoutAddress: String,
/**
* Match key for PAYIN leg
*/
@ColumnInfo(name = "payin_hash")
val payinHash: String?,
/** Refund destination address */
@ColumnInfo(name = "refund_address")
val refundAddress: String?,
/**
* Match key for PAYOUT leg
*/
@ColumnInfo(name = "payout_hash")
val payoutHash: String?,
@ColumnInfo(name = "external_tx_id")
val externalTxId: String?,
@ColumnInfo(name = "external_tx_url")
val externalTxUrl: String?,
/** Extra ID used for refunds */
@ColumnInfo(name = "refund_extra_id")
val refundExtraId: String?,
/**
* fixed / float
@ -82,49 +68,88 @@ data class ExpressExchangeEntity(
@ColumnInfo(name = "rate_type")
val rateType: String,
/**
* unknown
* exchange-tx-sent
* waiting
* waiting-tx-hash
* expired
* confirming
* exchanging
* sending
* finished
* failed
* tx-failed
* refunded
* verifying
* paused
*/
@ColumnInfo(name = "status")
val status: String,
/** External transaction ID (CEX only) */
@ColumnInfo(name = "external_tx_id")
val externalTxId: String?,
/** Transaction status reported by the provider */
@ColumnInfo(name = "external_tx_status")
val externalTxStatus: String?,
/** URL to view the transaction details (CEX only) */
@ColumnInfo(name = "external_tx_url")
val externalTxUrl: String?,
/** Blockchain hash of the pay-in transaction */
@ColumnInfo(name = "payin_hash")
val payinHash: String?,
/** Blockchain hash of the payout transaction */
@ColumnInfo(name = "payout_hash")
val payoutHash: String?,
/** Network used for the refund transaction */
@ColumnInfo(name = "refund_network")
val refundNetwork: String?,
/** Refunded token contract address */
@ColumnInfo(name = "refund_contract_address")
val refundContractAddress: String?,
/** Transaction creation timestamp in ISO-8601 format */
@ColumnInfo(name = "created_at")
val createdAt: Long,
val createdAt: String,
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
/** Pay-in expiration timestamp in ISO-8601 format */
@ColumnInfo(name = "pay_till")
val payTill: String?,
@Embedded(prefix = "refund_")
val refund: RefundEmbedded?,
/** Average provider exchange duration in seconds */
@ColumnInfo(name = "average_duration")
val averageDuration: Long?,
@Embedded(prefix = "from_")
val from: AssetEmbedded,
@Embedded(prefix = "to_")
val to: AssetEmbedded,
) {
data class AssetEmbedded(
@ColumnInfo(name = "contract_address")
val contractAddress: String,
@ColumnInfo(name = "network")
val network: String,
@ColumnInfo(name = "token_id")
val tokenId: String?,
@ColumnInfo(name = "raw_amount")
val rawAmount: String,
@ColumnInfo(name = "decimals")
val decimals: Int,
)
data class RefundEmbedded(
@ColumnInfo(name = "amount")
val amount: String,
@ColumnInfo(name = "network")
val network: String?,
@ColumnInfo(name = "token_id")
val tokenId: String?,
@ColumnInfo(name = "raw_amount")
val rawAmount: String?,
@ColumnInfo(name = "decimals")
val decimals: Int?,
/**
* Match key for REFUND leg
*/
@ColumnInfo(name = "hash")
val hash: String?,
/** Actual provider-confirmed amount. Present only for the [ExpressExchangeEntity.to] asset */
@ColumnInfo(name = "actual_amount")
val actualAmount: String?,
)
}

View file

@ -2,6 +2,11 @@ package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.*
/**
* Persisted representation of a single onramp transaction.
*
* Mirrors [com.tangem.datasource.api.onramp.models.response.OnrampItemResponse].
*/
@Entity(
tableName = "express_onramp",
foreignKeys = [
@ -13,7 +18,7 @@ import androidx.room.*
),
],
indices = [
Index(value = ["owner_address", "to_network", "updated_at"]),
Index(value = ["owner_address", "to_network", "created_at"]),
Index(value = ["owner_address", "payout_hash"]),
],
)
@ -23,71 +28,38 @@ 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 from which the source assets were taken for the exchange */
@ColumnInfo(name = "from_address")
val fromAddress: String,
* waiting-for-payment
* payment-processing
* paused
* verifying
* sending
* finished
* failed
* expired
* refunded
*/
@ColumnInfo(name = "status")
val status: String,
/** Address to which the assets were transferred for the exchange */
@ColumnInfo(name = "payin_address")
val payinAddress: String,
/**
* ISO-4217
*/
@ColumnInfo(name = "from_currency_code")
val fromCurrencyCode: String,
/** Extra ID used for the pay-in transaction */
@ColumnInfo(name = "payin_extra_id")
val payinExtraId: String?,
/**
* Decimal string
*/
@ColumnInfo(name = "from_amount")
val fromAmount: String,
/** Address that received the target assets */
@ColumnInfo(name = "payout_address")
val payoutAddress: String,
@ColumnInfo(name = "to_network")
val toNetwork: String,
/** Refund destination address */
@ColumnInfo(name = "refund_address")
val refundAddress: String?,
@ColumnInfo(name = "to_token_id")
val toTokenId: String?,
/**
* Estimated amount at creation moment
*/
@ColumnInfo(name = "to_expected_raw_amount")
val toExpectedRawAmount: String,
/**
* Actual provider-confirmed amount
*/
@ColumnInfo(name = "to_actual_raw_amount")
val toActualRawAmount: String?,
@ColumnInfo(name = "to_decimals")
val toDecimals: Int,
/**
* Match key with gateway_tx.hash
*/
@ColumnInfo(name = "payout_hash")
val payoutHash: String?,
@ColumnInfo(name = "external_tx_id")
val externalTxId: String?,
@ColumnInfo(name = "external_tx_url")
val externalTxUrl: String?,
/** Extra ID used for refunds */
@ColumnInfo(name = "refund_extra_id")
val refundExtraId: String?,
/**
* fixed / float
@ -95,28 +67,88 @@ data class ExpressOnrampEntity(
@ColumnInfo(name = "rate_type")
val rateType: String,
@ColumnInfo(name = "fail_reason")
val failReason: String?,
/**
* unknown
* exchange-tx-sent
* waiting
* waiting-tx-hash
* expired
* confirming
* exchanging
* sending
* finished
* failed
* tx-failed
* refunded
* verifying
* paused
*/
@ColumnInfo(name = "status")
val status: String,
/** External transaction ID (CEX only) */
@ColumnInfo(name = "external_tx_id")
val externalTxId: String?,
/** Transaction status reported by the provider */
@ColumnInfo(name = "external_tx_status")
val externalTxStatus: String?,
/** URL to view the transaction details (CEX only) */
@ColumnInfo(name = "external_tx_url")
val externalTxUrl: String?,
/** Blockchain hash of the pay-in transaction */
@ColumnInfo(name = "payin_hash")
val payinHash: String?,
/** Blockchain hash of the payout transaction */
@ColumnInfo(name = "payout_hash")
val payoutHash: String?,
/** Network used for the refund transaction (when status is refunded) */
@ColumnInfo(name = "refund_network")
val refundNetwork: String?,
/** Refunded token contract address */
@ColumnInfo(name = "refund_contract_address")
val refundContractAddress: String?,
/** Transaction creation timestamp in ISO-8601 format */
@ColumnInfo(name = "created_at")
val createdAt: Long,
val createdAt: String,
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
/** Pay-in expiration timestamp in ISO-8601 format */
@ColumnInfo(name = "pay_till")
val payTill: String?,
@Embedded(prefix = "refund_")
val refund: RefundEmbedded?,
/** Average provider exchange duration in seconds */
@ColumnInfo(name = "average_duration")
val averageDuration: Long?,
@Embedded(prefix = "from_")
val from: AssetEmbedded,
@Embedded(prefix = "to_")
val to: AssetEmbedded,
) {
data class RefundEmbedded(
data class AssetEmbedded(
/**
* ISO-4217
*/
@ColumnInfo(name = "currency_code")
val currencyCode: String?,
@ColumnInfo(name = "contract_address")
val contractAddress: String,
@ColumnInfo(name = "network")
val network: String,
@ColumnInfo(name = "decimals")
val decimals: Int,
@ColumnInfo(name = "amount")
val amount: String?,
val amount: String,
/** Actual provider-confirmed amount. Present only for the [ExpressOnrampEntity.to] asset */
@ColumnInfo(name = "actual_amount")
val actualAmount: String?,
)
}

View file

@ -0,0 +1,39 @@
package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.ColumnInfo
import androidx.room.Entity
/**
* Persisted sync state of the express tx history.
*
* Stored inside [com.tangem.datasource.local.txhistory.db.TxHistoryDatabase] on purpose: if the history tables are
* dropped (e.g. destructive migration), the sync state is wiped together with them and the history is re-synced
* from scratch.
*/
@Entity(
tableName = "express_sync_state",
primaryKeys = ["type", "address"],
)
data class ExpressSyncStateEntity(
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "address")
val address: String,
@ColumnInfo(name = "is_initial_completed")
val isInitialCompleted: Boolean,
@ColumnInfo(name = "after_cursor")
val afterCursor: String?,
@ColumnInfo(name = "delta_cursor")
val deltaCursor: String?,
) {
enum class Type {
EXCHANGE,
ONRAMP,
}
}

View file

@ -1,38 +0,0 @@
package com.tangem.datasource.local.txhistory.store
import androidx.datastore.core.DataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
internal class DefaultTxHistoryStore(
private val expressExchangeStore: DataStore<Map<CommonSyncStateKey, CommonSyncState>>,
private val expressOnrampStore: DataStore<Map<CommonSyncStateKey, CommonSyncState>>,
) : TxHistoryStore {
override fun expressExchangeSyncState(key: CommonSyncStateKey): Flow<CommonSyncState> {
return expressExchangeStore.data.map { map -> map.getOrDefault(key) }
}
override fun expressOnrampSyncState(key: CommonSyncStateKey): Flow<CommonSyncState> {
return expressOnrampStore.data.map { map -> map.getOrDefault(key) }
}
override suspend fun updateExpressExchangeSyncState(
key: CommonSyncStateKey,
value: CommonSyncState,
): CommonSyncState {
return expressExchangeStore.updateData { map -> map.plus(key to value) }
.getOrDefault(key)
}
override suspend fun updateExpressOnrampSyncState(
key: CommonSyncStateKey,
value: CommonSyncState,
): CommonSyncState {
return expressOnrampStore.updateData { map -> map.plus(key to value) }
.getOrDefault(key)
}
private fun Map<CommonSyncStateKey, CommonSyncState>.getOrDefault(key: CommonSyncStateKey): CommonSyncState =
this.getOrDefault(key, CommonSyncState.default(key))
}

View file

@ -1,27 +0,0 @@
package com.tangem.datasource.local.txhistory.store
import com.tangem.domain.models.account.AccountId
import kotlinx.serialization.Serializable
@Serializable
data class CommonSyncStateKey(
val accountId: AccountId,
val address: String,
)
@Serializable
data class CommonSyncState(
val accountId: AccountId,
val address: String,
val isInitialCompleted: Boolean,
val cursor: String?,
) {
companion object {
fun default(key: CommonSyncStateKey) = CommonSyncState(
accountId = key.accountId,
address = key.address,
isInitialCompleted = false,
cursor = null,
)
}
}

View file

@ -1,12 +0,0 @@
package com.tangem.datasource.local.txhistory.store
import kotlinx.coroutines.flow.Flow
interface TxHistoryStore {
fun expressExchangeSyncState(key: CommonSyncStateKey): Flow<CommonSyncState>
fun expressOnrampSyncState(key: CommonSyncStateKey): Flow<CommonSyncState>
suspend fun updateExpressExchangeSyncState(key: CommonSyncStateKey, value: CommonSyncState): CommonSyncState
suspend fun updateExpressOnrampSyncState(key: CommonSyncStateKey, value: CommonSyncState): CommonSyncState
}

View file

@ -0,0 +1,103 @@
package com.tangem.data.txhistory.repository
import com.tangem.data.txhistory.repository.converter.toEntity
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse
import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
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.entity.express.ExpressSyncStateEntity
import kotlinx.coroutines.flow.first
import javax.inject.Inject
/**
* Fetches express (exchange & onramp) transaction history from the API and persists it into the local database.
*
*/
internal class ExpressHistoryRepository @Inject constructor(
private val exchangeApi: TangemExpressApi,
private val onrampApi: OnrampApi,
private val expressHistoryDao: ExpressHistoryDao,
private val expressSyncStateDao: ExpressSyncStateDao,
) {
suspend fun fetchExchangeHistory(fromAddress: String, limit: Int = DEFAULT_LIMIT): ExchangeHistoryResponse {
val state = syncState(ExpressSyncStateEntity.Type.EXCHANGE, fromAddress)
val response = exchangeApi.getHistory(
fromAddress = fromAddress,
cursor = state?.afterCursor,
limit = limit,
).getOrThrow()
saveExchanges(ownerAddress = fromAddress, items = response.items)
return response
}
suspend fun fetchExchangeHistoryDelta(
fromAddress: String,
limit: Int = DEFAULT_LIMIT,
): ExchangeHistoryDeltaResponse {
val state = syncState(ExpressSyncStateEntity.Type.EXCHANGE, fromAddress)
val response = exchangeApi.getHistoryDelta(
fromAddress = fromAddress,
cursor = state?.deltaCursor,
limit = limit,
).getOrThrow()
saveExchanges(ownerAddress = fromAddress, items = response.items)
return response
}
suspend fun fetchOnrampHistory(payoutAddress: String, limit: Int = DEFAULT_LIMIT): OnrampHistoryResponse {
val state = syncState(ExpressSyncStateEntity.Type.ONRAMP, payoutAddress)
val response = onrampApi.getHistory(
payoutAddress = payoutAddress,
afterCursor = state?.afterCursor,
limit = limit,
).getOrThrow()
saveOnramps(ownerAddress = payoutAddress, items = response.items)
return response
}
suspend fun fetchOnrampHistoryDelta(
payoutAddress: String,
limit: Int = DEFAULT_LIMIT,
): OnrampHistoryDeltaResponse {
val state = syncState(ExpressSyncStateEntity.Type.ONRAMP, payoutAddress)
val response = onrampApi.getHistoryDelta(
payoutAddress = payoutAddress,
cursor = state?.deltaCursor,
limit = limit,
).getOrThrow()
saveOnramps(ownerAddress = payoutAddress, items = response.items)
return response
}
suspend fun syncState(type: ExpressSyncStateEntity.Type, address: String): ExpressSyncStateEntity? {
return expressSyncStateDao.observe(type = type.name, address = address).first()
}
private suspend fun saveExchanges(ownerAddress: String, items: List<ExchangeItemResponse>) {
expressHistoryDao.upsertExchanges(items.map { it.toEntity(ownerAddress) })
}
private suspend fun saveOnramps(ownerAddress: String, items: List<OnrampItemResponse>) {
expressHistoryDao.upsertOnramps(items.map { it.toEntity(ownerAddress) })
}
private companion object {
const val DEFAULT_LIMIT = 100
}
}

View file

@ -0,0 +1,91 @@
package com.tangem.data.txhistory.repository.converter
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
/**
* Maps API history items into their persisted [androidx.room.Entity] representations.
*
* @param ownerAddress address the history was requested for. Stored as the query key.
*/
internal fun ExchangeItemResponse.toEntity(ownerAddress: String): ExpressExchangeEntity {
return ExpressExchangeEntity(
txId = txId,
ownerAddress = ownerAddress,
providerId = providerId,
fromAddress = fromAddress,
payinAddress = payinAddress,
payinExtraId = payinExtraId,
payoutAddress = payoutAddress,
refundAddress = refundAddress,
refundExtraId = refundExtraId,
rateType = rateType,
status = status.name,
externalTxId = externalTxId,
externalTxStatus = externalTxStatus,
externalTxUrl = externalTxUrl,
payinHash = payinHash,
payoutHash = payoutHash,
refundNetwork = refundNetwork,
refundContractAddress = refundContractAddress,
createdAt = createdAt,
payTill = payTill,
averageDuration = averageDuration,
from = ExpressExchangeEntity.AssetEmbedded(
contractAddress = fromContractAddress,
network = fromNetwork,
decimals = fromDecimals,
amount = fromAmount,
actualAmount = null,
),
to = ExpressExchangeEntity.AssetEmbedded(
contractAddress = toContractAddress,
network = toNetwork,
decimals = toDecimals,
amount = toAmount,
actualAmount = toActualAmount,
),
)
}
internal fun OnrampItemResponse.toEntity(ownerAddress: String): ExpressOnrampEntity {
return ExpressOnrampEntity(
txId = txId,
ownerAddress = ownerAddress,
providerId = providerId,
fromAddress = fromAddress,
payinAddress = payinAddress,
payinExtraId = payinExtraId,
payoutAddress = payoutAddress,
refundAddress = refundAddress,
refundExtraId = refundExtraId,
rateType = rateType,
status = status.name,
externalTxId = externalTxId,
externalTxStatus = externalTxStatus,
externalTxUrl = externalTxUrl,
payinHash = payinHash,
payoutHash = payoutHash,
refundNetwork = refundNetwork,
refundContractAddress = refundContractAddress,
createdAt = createdAt,
payTill = payTill,
averageDuration = averageDuration,
from = ExpressOnrampEntity.AssetEmbedded(
contractAddress = fromContractAddress,
network = fromNetwork,
decimals = fromDecimals,
amount = fromAmount,
actualAmount = null,
),
to = ExpressOnrampEntity.AssetEmbedded(
contractAddress = toContractAddress,
network = toNetwork,
decimals = toDecimals,
amount = toAmount,
actualAmount = toActualAmount,
),
)
}

View file

@ -1,7 +1,7 @@
package com.tangem.data.txhistory.fetcher
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId

View file

@ -1,7 +1,7 @@
package com.tangem.data.txhistory.fetcher
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet

View file

@ -1,7 +1,7 @@
package com.tangem.data.txhistory.fetcher
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.SingleAccountListSupplier

View file

@ -0,0 +1,367 @@
package com.tangem.data.txhistory.repository
import com.google.common.truth.Truth.assertThat
import com.tangem.data.txhistory.repository.converter.toEntity
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse
import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
import com.tangem.datasource.api.express.models.response.ExpressPagination
import com.tangem.datasource.api.express.models.response.ExpressPaginationDelta
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
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.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ExpressHistoryRepositoryTest {
private val exchangeApi: TangemExpressApi = mockk()
private val onrampApi: OnrampApi = mockk()
private val expressHistoryDao: ExpressHistoryDao = mockk(relaxUnitFun = true)
private val expressSyncStateDao: ExpressSyncStateDao = mockk()
private val repository = ExpressHistoryRepository(
exchangeApi = exchangeApi,
onrampApi = onrampApi,
expressHistoryDao = expressHistoryDao,
expressSyncStateDao = expressSyncStateDao,
)
@BeforeEach
fun setup() {
clearMocks(exchangeApi, onrampApi, expressHistoryDao, expressSyncStateDao)
}
// region exchange history
@Test
fun `GIVEN sync state WHEN fetchExchangeHistory THEN passes after cursor and persists items`() = runTest {
// GIVEN
val item = createExchangeItem()
val response = ExchangeHistoryResponse(items = listOf(item), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
coEvery {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Success(response)
// WHEN
val result = repository.fetchExchangeHistory(fromAddress = ADDRESS)
// THEN
assertThat(result).isEqualTo(response)
coVerify(exactly = 1) {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
}
@Test
fun `GIVEN no sync state WHEN fetchExchangeHistory THEN passes null cursor`() = runTest {
// GIVEN
val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, state = null)
coEvery {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = null, limit = any())
} returns ApiResponse.Success(response)
// WHEN
repository.fetchExchangeHistory(fromAddress = ADDRESS)
// THEN
coVerify(exactly = 1) {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = null, limit = DEFAULT_LIMIT)
}
}
@Test
fun `GIVEN custom limit WHEN fetchExchangeHistory THEN forwards limit to api`() = runTest {
// GIVEN
val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
coEvery {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Success(response)
// WHEN
repository.fetchExchangeHistory(fromAddress = ADDRESS, limit = 25)
// THEN
coVerify(exactly = 1) {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = 25)
}
}
@Test
fun `GIVEN api error WHEN fetchExchangeHistory THEN throws and does not persist`() = runTest {
// GIVEN
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
val error = httpError()
coEvery {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Error(error).cast()
// WHEN
val thrown = runCatching { repository.fetchExchangeHistory(fromAddress = ADDRESS) }.exceptionOrNull()
// THEN
assertThat(thrown).isEqualTo(error)
coVerify(exactly = 0) { expressHistoryDao.upsertExchanges(any()) }
}
@Test
fun `GIVEN sync state WHEN fetchExchangeHistoryDelta THEN passes delta cursor and persists items`() = runTest {
// GIVEN
val item = createExchangeItem()
val response = ExchangeHistoryDeltaResponse(items = listOf(item), pagination = paginationDelta())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(deltaCursor = DELTA_CURSOR))
coEvery {
exchangeApi.getHistoryDelta(fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = any())
} returns ApiResponse.Success(response)
// WHEN
val result = repository.fetchExchangeHistoryDelta(fromAddress = ADDRESS)
// THEN
assertThat(result).isEqualTo(response)
coVerify(exactly = 1) {
exchangeApi.getHistoryDelta(fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
}
// endregion
// region onramp history
@Test
fun `GIVEN sync state WHEN fetchOnrampHistory THEN passes after cursor and persists items`() = runTest {
// GIVEN
val item = createOnrampItem()
val response = OnrampHistoryResponse(items = listOf(item), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
coEvery {
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Success(response)
// WHEN
val result = repository.fetchOnrampHistory(payoutAddress = ADDRESS)
// THEN
assertThat(result).isEqualTo(response)
coVerify(exactly = 1) {
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
}
@Test
fun `GIVEN no sync state WHEN fetchOnrampHistory THEN passes null cursor`() = runTest {
// GIVEN
val response = OnrampHistoryResponse(items = emptyList(), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, state = null)
coEvery {
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = null, limit = any())
} returns ApiResponse.Success(response)
// WHEN
repository.fetchOnrampHistory(payoutAddress = ADDRESS)
// THEN
coVerify(exactly = 1) {
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = null, limit = DEFAULT_LIMIT)
}
}
@Test
fun `GIVEN sync state WHEN fetchOnrampHistoryDelta THEN passes delta cursor and persists items`() = runTest {
// GIVEN
val item = createOnrampItem()
val response = OnrampHistoryDeltaResponse(items = listOf(item), pagination = paginationDelta())
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(deltaCursor = DELTA_CURSOR))
coEvery {
onrampApi.getHistoryDelta(payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = any())
} returns ApiResponse.Success(response)
// WHEN
val result = repository.fetchOnrampHistoryDelta(payoutAddress = ADDRESS)
// THEN
assertThat(result).isEqualTo(response)
coVerify(exactly = 1) {
onrampApi.getHistoryDelta(payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
}
@Test
fun `GIVEN api error WHEN fetchOnrampHistory THEN throws and does not persist`() = runTest {
// GIVEN
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
val error = httpError()
coEvery {
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Error(error).cast()
// WHEN
val thrown = runCatching { repository.fetchOnrampHistory(payoutAddress = ADDRESS) }.exceptionOrNull()
// THEN
assertThat(thrown).isEqualTo(error)
coVerify(exactly = 0) { expressHistoryDao.upsertOnramps(any()) }
}
// endregion
// region syncState
@Test
fun `GIVEN stored sync state WHEN syncState THEN returns first emitted value`() = runTest {
// GIVEN
val state = syncState(afterCursor = AFTER_CURSOR, deltaCursor = DELTA_CURSOR)
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, state)
// WHEN
val result = repository.syncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS)
// THEN
assertThat(result).isEqualTo(state)
}
@Test
fun `GIVEN multiple items WHEN fetchExchangeHistory THEN maps every item with owner address`() = runTest {
// GIVEN
val items = listOf(
createExchangeItem(txId = "tx-1"),
createExchangeItem(txId = "tx-2"),
)
val response = ExchangeHistoryResponse(items = items, pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
coEvery {
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Success(response)
val saved = slot<List<ExpressExchangeEntity>>()
coEvery { expressHistoryDao.upsertExchanges(capture(saved)) } returns Unit
// WHEN
repository.fetchExchangeHistory(fromAddress = ADDRESS)
// THEN
assertThat(saved.captured).isEqualTo(items.map { it.toEntity(ADDRESS) })
assertThat(saved.captured.map { it.ownerAddress }.toSet()).containsExactly(ADDRESS)
}
// endregion
private fun stubSyncState(type: ExpressSyncStateEntity.Type, address: String, state: ExpressSyncStateEntity?) {
coEvery { expressSyncStateDao.observe(type = type.name, address = address) } returns flowOf(state)
}
private fun syncState(afterCursor: String? = null, deltaCursor: String? = null) = ExpressSyncStateEntity(
type = ExpressSyncStateEntity.Type.EXCHANGE.name,
address = ADDRESS,
isInitialCompleted = true,
afterCursor = afterCursor,
deltaCursor = deltaCursor,
)
private fun pagination() = ExpressPagination(endCursor = "end", startDeltaCursor = "delta", hasMore = false)
private fun paginationDelta() = ExpressPaginationDelta(startCursor = "start", hasMore = false)
@Suppress("UNCHECKED_CAST")
private fun <T : Any> ApiResponse.Error.cast(): ApiResponse<T> = this as ApiResponse<T>
private fun httpError() = ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.INTERNAL_SERVER_ERROR,
message = "boom",
errorBody = null,
)
private fun createExchangeItem(txId: String = "exchange-tx-1") = ExchangeItemResponse(
txId = txId,
providerId = "changelly",
fromAddress = "0xfrom",
payinAddress = "0xpayin",
payinExtraId = null,
payoutAddress = "0xpayout",
refundAddress = null,
refundExtraId = null,
rateType = "float",
status = ExchangeItemResponse.Status.FINISHED,
externalTxId = null,
externalTxStatus = null,
externalTxUrl = null,
payinHash = "payin-hash",
payoutHash = "payout-hash",
refundNetwork = null,
refundContractAddress = null,
createdAt = "2026-06-01T00:00:00Z",
payTill = null,
averageDuration = null,
fromContractAddress = "0xfromContract",
fromNetwork = "ethereum",
fromDecimals = 18,
fromAmount = "1.0",
toContractAddress = "0xtoContract",
toNetwork = "bitcoin",
toDecimals = 8,
toAmount = "1.0",
toActualAmount = "0.99",
)
private fun createOnrampItem(txId: String = "onramp-tx-1") = OnrampItemResponse(
txId = txId,
providerId = "mercuryo",
fromAddress = "0xfrom",
payinAddress = "0xpayin",
payinExtraId = null,
payoutAddress = "0xpayout",
refundAddress = null,
refundExtraId = null,
rateType = "fixed",
status = OnrampItemResponse.Status.FINISHED,
externalTxId = null,
externalTxStatus = null,
externalTxUrl = null,
payinHash = "payin-hash",
payoutHash = "payout-hash",
refundNetwork = null,
refundContractAddress = null,
createdAt = "2026-06-01T00:00:00Z",
payTill = null,
averageDuration = null,
fromContractAddress = "0xfromContract",
fromNetwork = "usd",
fromDecimals = 2,
fromAmount = "100.0",
toContractAddress = "0xtoContract",
toNetwork = "bitcoin",
toDecimals = 8,
toAmount = "0.001",
toActualAmount = "0.99",
)
private companion object {
const val ADDRESS = "0xowner"
const val AFTER_CURSOR = "after-cursor"
const val DELTA_CURSOR = "delta-cursor"
const val DEFAULT_LIMIT = 100
}
}

View file

@ -0,0 +1,316 @@
package com.tangem.data.txhistory.repository.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ExpressHistoryConverterTest {
@Test
fun `GIVEN exchange item WHEN toEntity THEN all transaction fields are mapped`() {
// GIVEN
val item = createExchangeItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
assertThat(entity.txId).isEqualTo(item.txId)
assertThat(entity.ownerAddress).isEqualTo(OWNER_ADDRESS)
assertThat(entity.providerId).isEqualTo(item.providerId)
assertThat(entity.fromAddress).isEqualTo(item.fromAddress)
assertThat(entity.payinAddress).isEqualTo(item.payinAddress)
assertThat(entity.payinExtraId).isEqualTo(item.payinExtraId)
assertThat(entity.payoutAddress).isEqualTo(item.payoutAddress)
assertThat(entity.refundAddress).isEqualTo(item.refundAddress)
assertThat(entity.refundExtraId).isEqualTo(item.refundExtraId)
assertThat(entity.rateType).isEqualTo(item.rateType)
assertThat(entity.externalTxId).isEqualTo(item.externalTxId)
assertThat(entity.externalTxStatus).isEqualTo(item.externalTxStatus)
assertThat(entity.externalTxUrl).isEqualTo(item.externalTxUrl)
assertThat(entity.payinHash).isEqualTo(item.payinHash)
assertThat(entity.payoutHash).isEqualTo(item.payoutHash)
assertThat(entity.refundNetwork).isEqualTo(item.refundNetwork)
assertThat(entity.refundContractAddress).isEqualTo(item.refundContractAddress)
assertThat(entity.createdAt).isEqualTo(item.createdAt)
assertThat(entity.payTill).isEqualTo(item.payTill)
assertThat(entity.averageDuration).isEqualTo(item.averageDuration)
}
@Test
fun `GIVEN exchange item WHEN toEntity THEN status is stored as enum name`() {
// GIVEN
val item = createExchangeItem(status = ExchangeItemResponse.Status.FINISHED)
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
assertThat(entity.status).isEqualTo("FINISHED")
}
@Test
fun `GIVEN exchange item WHEN toEntity THEN from and to assets are mapped`() {
// GIVEN
val item = createExchangeItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
assertThat(entity.from.contractAddress).isEqualTo(item.fromContractAddress)
assertThat(entity.from.network).isEqualTo(item.fromNetwork)
assertThat(entity.from.decimals).isEqualTo(item.fromDecimals)
assertThat(entity.from.amount).isEqualTo(item.fromAmount)
// `from` asset never carries an actual amount
assertThat(entity.from.actualAmount).isNull()
assertThat(entity.to.contractAddress).isEqualTo(item.toContractAddress)
assertThat(entity.to.network).isEqualTo(item.toNetwork)
assertThat(entity.to.decimals).isEqualTo(item.toDecimals)
assertThat(entity.to.amount).isEqualTo(item.toAmount)
assertThat(entity.to.actualAmount).isEqualTo(item.toActualAmount)
}
@Test
fun `GIVEN exchange item with null optional fields WHEN toEntity THEN nulls are preserved`() {
// GIVEN
val item = createExchangeItem(
payinExtraId = null,
refundAddress = null,
refundExtraId = null,
externalTxId = null,
externalTxStatus = null,
externalTxUrl = null,
payinHash = null,
payoutHash = null,
refundNetwork = null,
refundContractAddress = null,
payTill = null,
averageDuration = null,
toActualAmount = null,
)
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
assertThat(entity.payinExtraId).isNull()
assertThat(entity.refundAddress).isNull()
assertThat(entity.refundExtraId).isNull()
assertThat(entity.externalTxId).isNull()
assertThat(entity.externalTxStatus).isNull()
assertThat(entity.externalTxUrl).isNull()
assertThat(entity.payinHash).isNull()
assertThat(entity.payoutHash).isNull()
assertThat(entity.refundNetwork).isNull()
assertThat(entity.refundContractAddress).isNull()
assertThat(entity.payTill).isNull()
assertThat(entity.averageDuration).isNull()
assertThat(entity.to.actualAmount).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)
// THEN
assertThat(entity.txId).isEqualTo(item.txId)
assertThat(entity.ownerAddress).isEqualTo(OWNER_ADDRESS)
assertThat(entity.providerId).isEqualTo(item.providerId)
assertThat(entity.fromAddress).isEqualTo(item.fromAddress)
assertThat(entity.payinAddress).isEqualTo(item.payinAddress)
assertThat(entity.payinExtraId).isEqualTo(item.payinExtraId)
assertThat(entity.payoutAddress).isEqualTo(item.payoutAddress)
assertThat(entity.refundAddress).isEqualTo(item.refundAddress)
assertThat(entity.refundExtraId).isEqualTo(item.refundExtraId)
assertThat(entity.rateType).isEqualTo(item.rateType)
assertThat(entity.externalTxId).isEqualTo(item.externalTxId)
assertThat(entity.externalTxStatus).isEqualTo(item.externalTxStatus)
assertThat(entity.externalTxUrl).isEqualTo(item.externalTxUrl)
assertThat(entity.payinHash).isEqualTo(item.payinHash)
assertThat(entity.payoutHash).isEqualTo(item.payoutHash)
assertThat(entity.refundNetwork).isEqualTo(item.refundNetwork)
assertThat(entity.refundContractAddress).isEqualTo(item.refundContractAddress)
assertThat(entity.createdAt).isEqualTo(item.createdAt)
assertThat(entity.payTill).isEqualTo(item.payTill)
assertThat(entity.averageDuration).isEqualTo(item.averageDuration)
}
@Test
fun `GIVEN onramp item WHEN toEntity THEN status is stored as enum name`() {
// GIVEN
val item = createOnrampItem(status = OnrampItemResponse.Status.WAITING_TX_HASH)
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
assertThat(entity.status).isEqualTo("WAITING_TX_HASH")
}
@Test
fun `GIVEN onramp item WHEN toEntity THEN from and to assets are mapped`() {
// GIVEN
val item = createOnrampItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
assertThat(entity.from.contractAddress).isEqualTo(item.fromContractAddress)
assertThat(entity.from.network).isEqualTo(item.fromNetwork)
assertThat(entity.from.decimals).isEqualTo(item.fromDecimals)
assertThat(entity.from.amount).isEqualTo(item.fromAmount)
assertThat(entity.from.actualAmount).isNull()
assertThat(entity.to.contractAddress).isEqualTo(item.toContractAddress)
assertThat(entity.to.network).isEqualTo(item.toNetwork)
assertThat(entity.to.decimals).isEqualTo(item.toDecimals)
assertThat(entity.to.amount).isEqualTo(item.toAmount)
assertThat(entity.to.actualAmount).isEqualTo(item.toActualAmount)
}
@Test
fun `GIVEN onramp item with null optional fields WHEN toEntity THEN nulls are preserved`() {
// GIVEN
val item = createOnrampItem(
payinExtraId = null,
refundAddress = null,
refundExtraId = null,
externalTxId = null,
externalTxStatus = null,
externalTxUrl = null,
payinHash = null,
payoutHash = null,
refundNetwork = null,
refundContractAddress = null,
payTill = null,
averageDuration = null,
toActualAmount = null,
)
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
assertThat(entity.payinExtraId).isNull()
assertThat(entity.refundAddress).isNull()
assertThat(entity.refundExtraId).isNull()
assertThat(entity.externalTxId).isNull()
assertThat(entity.externalTxStatus).isNull()
assertThat(entity.externalTxUrl).isNull()
assertThat(entity.payinHash).isNull()
assertThat(entity.payoutHash).isNull()
assertThat(entity.refundNetwork).isNull()
assertThat(entity.refundContractAddress).isNull()
assertThat(entity.payTill).isNull()
assertThat(entity.averageDuration).isNull()
assertThat(entity.to.actualAmount).isNull()
}
private fun createExchangeItem(
status: ExchangeItemResponse.Status = ExchangeItemResponse.Status.WAITING,
payinExtraId: String? = "payin-extra",
refundAddress: String? = "refund-address",
refundExtraId: String? = "refund-extra",
externalTxId: String? = "external-tx-id",
externalTxStatus: String? = "external-status",
externalTxUrl: String? = "https://provider.example/tx",
payinHash: String? = "payin-hash",
payoutHash: String? = "payout-hash",
refundNetwork: String? = "ethereum",
refundContractAddress: String? = "0xrefund",
payTill: String? = "2026-06-01T00:10:00Z",
averageDuration: Long? = 600L,
toActualAmount: String? = "0.99",
) = ExchangeItemResponse(
txId = "exchange-tx-1",
providerId = "changelly",
fromAddress = "0xfrom",
payinAddress = "0xpayin",
payinExtraId = payinExtraId,
payoutAddress = "0xpayout",
refundAddress = refundAddress,
refundExtraId = refundExtraId,
rateType = "float",
status = status,
externalTxId = externalTxId,
externalTxStatus = externalTxStatus,
externalTxUrl = externalTxUrl,
payinHash = payinHash,
payoutHash = payoutHash,
refundNetwork = refundNetwork,
refundContractAddress = refundContractAddress,
createdAt = "2026-06-01T00:00:00Z",
payTill = payTill,
averageDuration = averageDuration,
fromContractAddress = "0xfromContract",
fromNetwork = "ethereum",
fromDecimals = 18,
fromAmount = "1.0",
toContractAddress = "0xtoContract",
toNetwork = "bitcoin",
toDecimals = 8,
toAmount = "1.0",
toActualAmount = toActualAmount,
)
private fun createOnrampItem(
status: OnrampItemResponse.Status = OnrampItemResponse.Status.WAITING,
payinExtraId: String? = "payin-extra",
refundAddress: String? = "refund-address",
refundExtraId: String? = "refund-extra",
externalTxId: String? = "external-tx-id",
externalTxStatus: String? = "external-status",
externalTxUrl: String? = "https://provider.example/tx",
payinHash: String? = "payin-hash",
payoutHash: String? = "payout-hash",
refundNetwork: String? = "ethereum",
refundContractAddress: String? = "0xrefund",
payTill: String? = "2026-06-01T00:10:00Z",
averageDuration: Long? = 600L,
toActualAmount: String? = "0.99",
) = OnrampItemResponse(
txId = "onramp-tx-1",
providerId = "mercuryo",
fromAddress = "0xfrom",
payinAddress = "0xpayin",
payinExtraId = payinExtraId,
payoutAddress = "0xpayout",
refundAddress = refundAddress,
refundExtraId = refundExtraId,
rateType = "fixed",
status = status,
externalTxId = externalTxId,
externalTxStatus = externalTxStatus,
externalTxUrl = externalTxUrl,
payinHash = payinHash,
payoutHash = payoutHash,
refundNetwork = refundNetwork,
refundContractAddress = refundContractAddress,
createdAt = "2026-06-01T00:00:00Z",
payTill = payTill,
averageDuration = averageDuration,
fromContractAddress = "0xfromContract",
fromNetwork = "usd",
fromDecimals = 2,
fromAmount = "100.0",
toContractAddress = "0xtoContract",
toNetwork = "bitcoin",
toDecimals = 8,
toAmount = "0.001",
toActualAmount = toActualAmount,
)
private companion object {
const val OWNER_ADDRESS = "0xowner"
}
}