Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 17:43:43 +04:00
parent 6e92e35a38
commit 4d0cb6a243
24 changed files with 697 additions and 887 deletions

View file

@ -78,7 +78,7 @@ interface TangemExpressApi {
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String?,
@Query("txId") txId: String,
): ApiResponse<ExchangeStatusResponse>
): ApiResponse<ExchangeItemResponse>
@POST("exchange-sent")
suspend fun exchangeSent(

View file

@ -12,9 +12,12 @@ data class ExchangeItemResponse(
@Json(name = "providerId")
val providerId: String,
/** Address from which the source assets were sent */
/**
* 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.
*/
@Json(name = "fromAddress")
val fromAddress: String,
val fromAddress: String?,
/** Address to which the source assets were transferred for the exchange */
@Json(name = "payinAddress")
@ -40,17 +43,17 @@ data class ExchangeItemResponse(
@Json(name = "rateType")
val rateType: String,
/**
* Raw backend status string, kept unparsed so a new value never breaks deserialization.
* Typed view: [com.tangem.domain.express.models.ExpressExchangeStatus].
*/
@Json(name = "status")
val status: Status,
val status: String,
/** 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?,
@ -75,6 +78,10 @@ data class ExchangeItemResponse(
@Json(name = "createdAt")
val createdAt: String,
/** Transaction last-update timestamp in ISO-8601 format */
@Json(name = "updatedAt")
val updatedAt: String,
/** Pay-in expiration timestamp in ISO-8601 format */
@Json(name = "payTill")
val payTill: String?,
@ -107,50 +114,4 @@ data class ExchangeItemResponse(
@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

@ -9,7 +9,7 @@ data class ExpressPagination(
val endCursor: String?,
@Json(name = "startDeltaCursor")
val startDeltaCursor: String?,
@Json(name = "hasNextPage")
@Json(name = "hasMore")
val hasMore: Boolean,
)

View file

@ -6,7 +6,7 @@ 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
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampCurrencyDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampPairDTO
@ -87,7 +87,7 @@ interface OnrampApi {
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String?,
@Query("txId") txId: String,
): ApiResponse<OnrampStatusResponse>
): ApiResponse<OnrampItemResponse>
@GET("history/onramp")
suspend fun getHistory(

View file

@ -12,87 +12,49 @@ data class OnrampItemResponse(
@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,
/**
* Raw backend status string, kept unparsed so a new value never breaks deserialization.
* Typed view: [com.tangem.domain.express.models.ExpressOnrampStatus].
*/
@Json(name = "status")
val status: Status,
val status: String,
/** External transaction ID (CEX only) */
/** Failure reason reported by the provider */
@Json(name = "failReason")
val failReason: String?,
/** External transaction ID reported by the provider in the webhook */
@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) */
/** URL to view the transaction details on the provider side (not provided by all providers) */
@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?,
/** Transaction last-update timestamp in ISO-8601 format */
@Json(name = "updatedAt")
val updatedAt: String,
// endregion
// region fromAsset info
@Json(name = "fromContractAddress")
val fromContractAddress: String,
@Json(name = "fromNetwork")
val fromNetwork: String,
@Json(name = "fromDecimals")
val fromDecimals: Int,
// region fromAsset (fiat) info
@Json(name = "fromCurrencyCode")
val fromCurrencyCode: String,
@Json(name = "fromAmount")
val fromAmount: String,
@Json(name = "fromPrecision")
val fromPrecision: Int,
// endregion
// region toAsset info
@ -102,55 +64,19 @@ data class OnrampItemResponse(
val toNetwork: String,
@Json(name = "toDecimals")
val toDecimals: Int,
/** Provider-promised amount, received from the provider in the webhook */
@Json(name = "toAmount")
val toAmount: String,
val toAmount: String?,
/** Actual amount delivered to the user, received from the provider in the webhook */
@Json(name = "toActualAmount")
val toActualAmount: String?,
// endregion
) {
enum class Status {
@Json(name = "paymentMethod")
val paymentMethod: String,
@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,
}
}
@Json(name = "countryCode")
val countryCode: String,
)

View file

@ -0,0 +1,79 @@
package com.tangem.datasource.local.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.
*/
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,
externalTxId = externalTxId,
externalTxUrl = externalTxUrl,
payinHash = payinHash,
payoutHash = payoutHash,
refundNetwork = refundNetwork,
refundContractAddress = refundContractAddress,
createdAt = createdAt,
updatedAt = updatedAt,
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,
),
)
}
fun OnrampItemResponse.toEntity(ownerAddress: String): ExpressOnrampEntity {
return ExpressOnrampEntity(
txId = txId,
ownerAddress = ownerAddress,
providerId = providerId,
payoutAddress = payoutAddress,
status = status,
failReason = failReason,
externalTxId = externalTxId,
externalTxUrl = externalTxUrl,
payoutHash = payoutHash,
createdAt = createdAt,
updatedAt = updatedAt,
fromCurrencyCode = fromCurrencyCode,
fromAmount = fromAmount,
fromPrecision = fromPrecision,
to = ExpressOnrampEntity.AssetEmbedded(
contractAddress = toContractAddress,
network = toNetwork,
decimals = toDecimals,
amount = toAmount,
actualAmount = toActualAmount,
),
paymentMethod = paymentMethod,
countryCode = countryCode,
)
}

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.local.converter
import com.tangem.datasource.api.express.models.response.ExchangeProvider
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
/**
* Maps an [ExchangeProvider] API response into its persisted [ExpressProviderEntity] representation.
*
* `type` is stored as the [com.tangem.datasource.api.express.models.response.ExchangeProviderType] name
* (DEX / CEX / DEX_BRIDGE / ONRAMP); `slippage` as a plain decimal string.
*/
fun ExchangeProvider.toEntity(): ExpressProviderEntity {
return ExpressProviderEntity(
id = id,
name = name,
type = type.name,
imageLarge = imageLargeUrl,
imageSmall = imageSmallUrl,
termsOfUse = termsOfUse,
privacyPolicy = privacyPolicy,
isRecommended = isRecommended,
slippage = slippage?.toPlainString(),
isExchangeOnlyWithinSingleAddress = isExchangeOnlyWithinSingleAddress,
isExtraIdSupported = isExtraIdSupported,
)
}

View file

@ -2,6 +2,7 @@ package com.tangem.datasource.local.txhistory.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
@ -21,56 +22,76 @@ interface ExpressHistoryDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertOnramps(items: List<ExpressOnrampEntity>)
/**
* All persisted providers keyed by [ExpressProviderEntity.id]
*/
@Query("SELECT * FROM express_provider")
fun getProvidersById(): Flow<Map<@MapColumn(columnName = "id") String, ExpressProviderEntity>>
/**
* 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`.
*
* loading the whole table; [activeStatuses] keeps in-progress deals visible even outside the window.
*/
@Query(
"""
SELECT *
FROM express_exchange
SELECT * FROM express_exchange
WHERE owner_address = :ownerAddress
AND from_network = :network
AND from_contract_address = :contract
AND (created_at >= :fromCreatedAtIso OR status IN (:activeStatuses))
ORDER BY created_at DESC
""",
)
fun observeExchanges(ownerAddress: String): Flow<List<ExpressExchangeEntity>>
fun observeOutgoingSwaps(
ownerAddress: String,
network: String,
contract: String,
fromCreatedAtIso: String,
activeStatuses: List<String>,
): Flow<List<ExpressExchangeEntity>>
/**
* 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`.
*/
@Query(
"""
SELECT *
FROM express_onramp
WHERE owner_address = :ownerAddress
SELECT * FROM express_exchange
WHERE to_network = :network
AND to_contract_address = :contract
AND (created_at >= :fromCreatedAtIso OR status IN (:activeStatuses))
ORDER BY created_at DESC
""",
)
fun observeOnramps(ownerAddress: String): Flow<List<ExpressOnrampEntity>>
fun observeIncomingSwaps(
network: String,
contract: String,
fromCreatedAtIso: String,
activeStatuses: List<String>,
): Flow<List<ExpressExchangeEntity>>
/**
* Onramp is always incoming: [ExpressOnrampEntity.ownerAddress] == payoutAddress. Join by `payout_hash`.
*/
@Query(
"""
SELECT *
FROM express_exchange
SELECT * FROM express_onramp
WHERE owner_address = :ownerAddress
AND payin_hash = :hash
LIMIT 1
AND to_network = :network
AND to_contract_address = :contract
AND (created_at >= :fromCreatedAtIso OR status IN (:activeStatuses))
ORDER BY created_at DESC
""",
)
suspend fun findExchangeByPayinHash(ownerAddress: String, hash: String): ExpressExchangeEntity?
@Query(
"""
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
AND payout_hash = :hash
LIMIT 1
""",
)
suspend fun findExchangeByPayoutHash(ownerAddress: String, hash: String): ExpressExchangeEntity?
@Query(
"""
SELECT *
FROM express_onramp
WHERE owner_address = :ownerAddress
AND payout_hash = :hash
LIMIT 1
""",
)
suspend fun findOnrampByPayoutHash(ownerAddress: String, hash: String): ExpressOnrampEntity?
fun observeIncomingOnramps(
ownerAddress: String,
network: String,
contract: String,
fromCreatedAtIso: String,
activeStatuses: List<String>,
): Flow<List<ExpressOnrampEntity>>
}

View file

@ -10,10 +10,11 @@ import androidx.room.*
@Entity(
tableName = "express_exchange",
indices = [
Index(value = ["owner_address", "from_network", "created_at"]),
Index(value = ["owner_address", "payin_hash"]),
Index(value = ["owner_address", "payout_hash"]),
Index(value = ["provider_id"]),
// Outgoing swaps lookup (observeOutgoingSwaps): owner + from-asset equality, created_at range/sort.
Index(value = ["owner_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"]),
],
)
data class ExpressExchangeEntity(
@ -31,9 +32,12 @@ data class ExpressExchangeEntity(
@ColumnInfo(name = "provider_id")
val providerId: String,
/** Address from which the source assets were sent */
/**
* 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.
*/
@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")
@ -62,20 +66,8 @@ data class ExpressExchangeEntity(
val rateType: String,
/**
* unknown
* exchange-tx-sent
* waiting
* waiting-tx-hash
* expired
* confirming
* exchanging
* sending
* finished
* failed
* tx-failed
* refunded
* verifying
* paused
* Raw backend status string, persisted as-is (kept unparsed so a new value never breaks anything).
* Typed view: [com.tangem.domain.express.models.ExpressExchangeStatus].
*/
@ColumnInfo(name = "status")
val status: String,
@ -84,10 +76,6 @@ data class ExpressExchangeEntity(
@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?,
@ -112,6 +100,10 @@ data class ExpressExchangeEntity(
@ColumnInfo(name = "created_at")
val createdAt: String,
/** Transaction last-update timestamp in ISO-8601 format */
@ColumnInfo(name = "updated_at")
val updatedAt: String,
/** Pay-in expiration timestamp in ISO-8601 format */
@ColumnInfo(name = "pay_till")
val payTill: String?,

View file

@ -10,9 +10,8 @@ import androidx.room.*
@Entity(
tableName = "express_onramp",
indices = [
Index(value = ["owner_address", "to_network", "created_at"]),
Index(value = ["owner_address", "payout_hash"]),
Index(value = ["provider_id"]),
// Incoming onramp lookup (observeIncomingOnramps): owner + to-asset equality, created_at range/sort.
Index(value = ["owner_address", "to_network", "to_contract_address", "created_at"]),
],
)
data class ExpressOnrampEntity(
@ -30,100 +29,61 @@ data class ExpressOnrampEntity(
@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,
/** Address to which the assets were transferred for the exchange */
@ColumnInfo(name = "payin_address")
val payinAddress: String,
/** Extra ID used for the pay-in transaction */
@ColumnInfo(name = "payin_extra_id")
val payinExtraId: String?,
/** Address that received the target assets */
@ColumnInfo(name = "payout_address")
val payoutAddress: String,
/** Refund destination address */
@ColumnInfo(name = "refund_address")
val refundAddress: String?,
/** Extra ID used for refunds */
@ColumnInfo(name = "refund_extra_id")
val refundExtraId: String?,
/**
* fixed / float
*/
@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
* Raw backend status string, persisted as-is (kept unparsed so a new value never breaks anything).
* Typed view: [com.tangem.domain.express.models.ExpressOnrampStatus].
*/
@ColumnInfo(name = "status")
val status: String,
/** External transaction ID (CEX only) */
/** Failure reason reported by the provider */
@ColumnInfo(name = "fail_reason")
val failReason: String?,
/** External transaction ID reported by the provider in the webhook */
@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) */
/** URL to view the transaction details on the provider side (not provided by all providers) */
@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: String,
/** Pay-in expiration timestamp in ISO-8601 format */
@ColumnInfo(name = "pay_till")
val payTill: String?,
/** Transaction last-update timestamp in ISO-8601 format */
@ColumnInfo(name = "updated_at")
val updatedAt: String,
/** Average provider exchange duration in seconds */
@ColumnInfo(name = "average_duration")
val averageDuration: Long?,
/** Fiat currency code of the source funds */
@ColumnInfo(name = "from_currency_code")
val fromCurrencyCode: String,
@Embedded(prefix = "from_")
val from: AssetEmbedded,
/** Fiat amount of the source funds */
@ColumnInfo(name = "from_amount")
val fromAmount: String,
/** Number of decimal places of the source fiat currency */
@ColumnInfo(name = "from_precision")
val fromPrecision: Int,
@Embedded(prefix = "to_")
val to: AssetEmbedded,
@ColumnInfo(name = "payment_method")
val paymentMethod: String,
@ColumnInfo(name = "country_code")
val countryCode: String,
) {
data class AssetEmbedded(
@ -137,10 +97,11 @@ data class ExpressOnrampEntity(
@ColumnInfo(name = "decimals")
val decimals: Int,
/** Provider-promised amount. Present only if the provider reported it */
@ColumnInfo(name = "amount")
val amount: String,
val amount: String?,
/** Actual provider-confirmed amount. Present only for the [ExpressOnrampEntity.to] asset */
/** Actual provider-confirmed amount delivered to the user */
@ColumnInfo(name = "actual_amount")
val actualAmount: String?,
)

View file

@ -4,9 +4,13 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(
tableName = "express_provider",
)
/**
* Persisted representation of an express provider.
*
* Mirrors [com.tangem.datasource.api.express.models.response.ExchangeProvider]. Mapped into
* [com.tangem.domain.express.models.ExpressProvider] when read back.
*/
@Entity(tableName = "express_provider")
data class ExpressProviderEntity(
@PrimaryKey
@ -16,9 +20,34 @@ data class ExpressProviderEntity(
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "icon_url")
val iconUrl: String,
/** Raw provider type (`dex` / `cex` / `dex-bridge` / `onramp`). Typed view: ExpressProviderType. */
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "provider_url")
val providerUrl: String,
/** Large logo image URL. */
@ColumnInfo(name = "image_large")
val imageLarge: String,
/** Small logo image URL. */
@ColumnInfo(name = "image_small")
val imageSmall: String,
@ColumnInfo(name = "terms_of_use")
val termsOfUse: String?,
@ColumnInfo(name = "privacy_policy")
val privacyPolicy: String?,
@ColumnInfo(name = "is_recommended")
val isRecommended: Boolean,
/** Raw decimal string (BigDecimal) or `null`. */
@ColumnInfo(name = "slippage")
val slippage: String?,
@ColumnInfo(name = "is_exchange_only_within_single_address")
val isExchangeOnlyWithinSingleAddress: Boolean,
@ColumnInfo(name = "is_extra_id_supported")
val isExtraIdSupported: Boolean,
)

View file

@ -0,0 +1,272 @@
package com.tangem.datasource.local.converter
import com.google.common.truth.Truth
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
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)
Truth.assertThat(entity.payinExtraId).isEqualTo(item.payinExtraId)
Truth.assertThat(entity.payoutAddress).isEqualTo(item.payoutAddress)
Truth.assertThat(entity.refundAddress).isEqualTo(item.refundAddress)
Truth.assertThat(entity.refundExtraId).isEqualTo(item.refundExtraId)
Truth.assertThat(entity.rateType).isEqualTo(item.rateType)
Truth.assertThat(entity.externalTxId).isEqualTo(item.externalTxId)
Truth.assertThat(entity.externalTxUrl).isEqualTo(item.externalTxUrl)
Truth.assertThat(entity.payinHash).isEqualTo(item.payinHash)
Truth.assertThat(entity.payoutHash).isEqualTo(item.payoutHash)
Truth.assertThat(entity.refundNetwork).isEqualTo(item.refundNetwork)
Truth.assertThat(entity.refundContractAddress).isEqualTo(item.refundContractAddress)
Truth.assertThat(entity.createdAt).isEqualTo(item.createdAt)
Truth.assertThat(entity.updatedAt).isEqualTo(item.updatedAt)
Truth.assertThat(entity.payTill).isEqualTo(item.payTill)
Truth.assertThat(entity.averageDuration).isEqualTo(item.averageDuration)
}
@Test
fun `GIVEN exchange item WHEN toEntity THEN status is stored as raw string`() {
// GIVEN
val item = createExchangeItem(status = "finished")
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.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
Truth.assertThat(entity.from.contractAddress).isEqualTo(item.fromContractAddress)
Truth.assertThat(entity.from.network).isEqualTo(item.fromNetwork)
Truth.assertThat(entity.from.decimals).isEqualTo(item.fromDecimals)
Truth.assertThat(entity.from.amount).isEqualTo(item.fromAmount)
// `from` asset never carries an actual amount
Truth.assertThat(entity.from.actualAmount).isNull()
Truth.assertThat(entity.to.contractAddress).isEqualTo(item.toContractAddress)
Truth.assertThat(entity.to.network).isEqualTo(item.toNetwork)
Truth.assertThat(entity.to.decimals).isEqualTo(item.toDecimals)
Truth.assertThat(entity.to.amount).isEqualTo(item.toAmount)
Truth.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,
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
Truth.assertThat(entity.payinExtraId).isNull()
Truth.assertThat(entity.refundAddress).isNull()
Truth.assertThat(entity.refundExtraId).isNull()
Truth.assertThat(entity.externalTxId).isNull()
Truth.assertThat(entity.externalTxUrl).isNull()
Truth.assertThat(entity.payinHash).isNull()
Truth.assertThat(entity.payoutHash).isNull()
Truth.assertThat(entity.refundNetwork).isNull()
Truth.assertThat(entity.refundContractAddress).isNull()
Truth.assertThat(entity.payTill).isNull()
Truth.assertThat(entity.averageDuration).isNull()
Truth.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
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)
Truth.assertThat(entity.externalTxId).isEqualTo(item.externalTxId)
Truth.assertThat(entity.externalTxUrl).isEqualTo(item.externalTxUrl)
Truth.assertThat(entity.payoutHash).isEqualTo(item.payoutHash)
Truth.assertThat(entity.createdAt).isEqualTo(item.createdAt)
Truth.assertThat(entity.updatedAt).isEqualTo(item.updatedAt)
Truth.assertThat(entity.fromCurrencyCode).isEqualTo(item.fromCurrencyCode)
Truth.assertThat(entity.fromAmount).isEqualTo(item.fromAmount)
Truth.assertThat(entity.fromPrecision).isEqualTo(item.fromPrecision)
Truth.assertThat(entity.paymentMethod).isEqualTo(item.paymentMethod)
Truth.assertThat(entity.countryCode).isEqualTo(item.countryCode)
}
@Test
fun `GIVEN onramp item WHEN toEntity THEN status is stored as raw string`() {
// GIVEN
val item = createOnrampItem(status = "waiting-for-payment")
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.status).isEqualTo("waiting-for-payment")
}
@Test
fun `GIVEN onramp item WHEN toEntity THEN to asset is mapped`() {
// GIVEN
val item = createOnrampItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.to.contractAddress).isEqualTo(item.toContractAddress)
Truth.assertThat(entity.to.network).isEqualTo(item.toNetwork)
Truth.assertThat(entity.to.decimals).isEqualTo(item.toDecimals)
Truth.assertThat(entity.to.amount).isEqualTo(item.toAmount)
Truth.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(
failReason = null,
externalTxId = null,
externalTxUrl = null,
payoutHash = null,
toAmount = null,
toActualAmount = null,
)
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.failReason).isNull()
Truth.assertThat(entity.externalTxId).isNull()
Truth.assertThat(entity.externalTxUrl).isNull()
Truth.assertThat(entity.payoutHash).isNull()
Truth.assertThat(entity.to.amount).isNull()
Truth.assertThat(entity.to.actualAmount).isNull()
}
private fun createExchangeItem(
status: String = "waiting",
payinExtraId: String? = "payin-extra",
refundAddress: String? = "refund-address",
refundExtraId: String? = "refund-extra",
externalTxId: String? = "external-tx-id",
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,
externalTxUrl = externalTxUrl,
payinHash = payinHash,
payoutHash = payoutHash,
refundNetwork = refundNetwork,
refundContractAddress = refundContractAddress,
createdAt = "2026-06-01T00:00:00Z",
updatedAt = "2026-06-01T00:05: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: String = "waiting-for-payment",
failReason: String? = "fail-reason",
externalTxId: String? = "external-tx-id",
externalTxUrl: String? = "https://provider.example/tx",
payoutHash: String? = "payout-hash",
toAmount: String? = "0.001",
toActualAmount: String? = "0.00099",
) = OnrampItemResponse(
txId = "onramp-tx-1",
providerId = "mercuryo",
payoutAddress = "0xpayout",
status = status,
failReason = failReason,
externalTxId = externalTxId,
externalTxUrl = externalTxUrl,
payoutHash = payoutHash,
createdAt = "2026-06-01T00:00:00Z",
updatedAt = "2026-06-01T00:05:00Z",
fromCurrencyCode = "USD",
fromAmount = "100.0",
fromPrecision = 2,
toContractAddress = "0xtoContract",
toNetwork = "bitcoin",
toDecimals = 8,
toAmount = toAmount,
toActualAmount = toActualAmount,
paymentMethod = "card",
countryCode = "US",
)
private companion object {
const val OWNER_ADDRESS = "0xowner"
}
}