Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-24 19:26:35 +03:00
parent e95243ab79
commit e70f8be7c8
14 changed files with 392 additions and 357 deletions

View file

@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
@ -32,6 +33,8 @@ import com.tangem.features.tokendetails.ExpressTransactionsComponent
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.tokenreceive.TokenReceiveComponent
import com.tangem.features.txhistory.component.TxHistoryComponent
import com.tangem.features.txhistory.component.TxHistoryDetailsComponent
import com.tangem.features.txhistory.component.TxHistoryDetailsSlotConfig
import com.tangem.features.yield.supply.api.YieldSupplyComponent
import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent
import dagger.assisted.Assisted
@ -44,6 +47,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
@Assisted params: TokenDetailsComponent.Params,
tokenMarketBlockComponentFactory: TokenMarketBlockComponent.Factory,
txHistoryComponentFactory: TxHistoryComponent.Factory,
private val txHistoryDetailsComponentFactory: TxHistoryDetailsComponent.Factory,
expressTransactionsComponentFactory: ExpressTransactionsComponent.Factory,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
private val yieldSupplyWarningComponentFactory: YieldSupplyDepositedWarningComponent.Factory,
@ -59,6 +63,9 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
userWalletId = params.userWalletId,
currency = params.currency,
openExplorer = model::onExploreClick,
onTxDetailsRequested = { txHistoryInfo ->
model.txDetailsNavigation.activate(TxHistoryDetailsSlotConfig(txHistoryInfo))
},
),
)
@ -88,6 +95,24 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
},
)
private val txHistoryDetailsSlot = childSlot(
key = TX_HISTORY_DETAILS_SLOT_KEY,
source = model.txDetailsNavigation,
serializer = null,
handleBackButton = true,
childFactory = { config, ctx ->
txHistoryDetailsComponentFactory.create(
context = childByContext(ctx),
params = TxHistoryDetailsComponent.Params(
txHistoryInfo = config.txHistoryInfo,
userWalletId = params.userWalletId,
currency = params.currency,
onDismiss = model.txDetailsNavigation::dismiss,
),
)
},
)
private val tokenMarketBlockComponent = params.currency.toTokenMarketParam()?.let { tokenMarketParams ->
tokenMarketBlockComponentFactory.create(
appComponentContext = child("tokenMarketBlockComponent"),
@ -109,6 +134,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
override fun Content(modifier: Modifier) {
val bottomSheet by bottomSheetSlot.subscribeAsState()
val ratingSlotState by ratingSlot.subscribeAsState()
val txHistoryDetails by txHistoryDetailsSlot.subscribeAsState()
NavigationBar3ButtonsScrim()
if (LocalRedesignEnabled.current) {
@ -136,6 +162,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
}
bottomSheet.child?.instance?.BottomSheet()
txHistoryDetails.child?.instance?.BottomSheet()
}
private fun CryptoCurrency.toTokenMarketParam(): TokenMarketBlockComponent.Params? {
@ -204,5 +231,6 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
companion object {
private const val RATING_SLOT_KEY = "ratingSlot"
private const val TX_HISTORY_DETAILS_SLOT_KEY = "txHistoryDetailsSlot"
}
}

View file

@ -128,6 +128,7 @@ import com.tangem.features.tokendetails.ExpressTransactionsEvent
import com.tangem.features.tokendetails.ExpressTransactionsEventListener
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.tokendetails.impl.R
import com.tangem.features.txhistory.component.TxHistoryDetailsSlotConfig
import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter
import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
@ -232,6 +233,7 @@ internal class TokenDetailsModel @Inject constructor(
val bottomSheetNavigation: SlotNavigation<TokenDetailsBottomSheetConfig> = SlotNavigation()
val ratingSlotNavigation = SlotNavigation<RatingComponent.Params>()
val txDetailsNavigation = SlotNavigation<TxHistoryDetailsSlotConfig>()
private val stateFactory = TokenDetailsStateFactory(
currentStateProvider = Provider { uiState.value },

View file

@ -6,8 +6,10 @@ import androidx.compose.runtime.Stable
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.features.txhistory.entity.TxHistoryUM
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@Stable
@ -25,6 +27,7 @@ interface TxHistoryComponent {
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val openExplorer: () -> Unit,
val onTxDetailsRequested: (Flow<TxHistoryInfo>) -> Unit,
)
interface Factory : ComponentFactory<Params, TxHistoryComponent>

View file

@ -3,14 +3,14 @@ package com.tangem.features.txhistory.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.model.TxHistoryInfo
import kotlinx.coroutines.flow.Flow
interface TxHistoryDetailsComponent : ComposableBottomSheetComponent {
data class Params(
val txInfo: Flow<TxInfo>,
val txHistoryInfo: Flow<TxHistoryInfo>,
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val onDismiss: () -> Unit,

View file

@ -12,10 +12,9 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.explorerHash
import com.tangem.features.txhistory.impl.R
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.utils.StringsSigns
@ -30,8 +29,8 @@ import java.math.BigDecimal
* express statuses collapse into the three [Status] buckets (those drive title/icon/amount colors in the row UI).
*
* The counterparty ticker symbol+icon come from the resolved [ExpressTransactionAsset.cryptoCurrency] (swap);
* onramp shows the real fiat code with no icon yet (fiat carries no `CryptoCurrency`). The row click opens the
* explorer.
* onramp shows the real fiat code with no icon yet (fiat carries no `CryptoCurrency`). The row click routes through
* [TxHistoryUiActions.onTransactionClick] (express rows open the in-app details sheet).
*/
internal class ExpressTxToTransactionItemUMConverter(
private val currency: CryptoCurrency,
@ -39,6 +38,8 @@ internal class ExpressTxToTransactionItemUMConverter(
) : Converter<ExpressTx, TransactionItemUM> {
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
private val exchangeStatusConverter = ExpressExchangeStatusToUiStatusConverter()
private val onrampStatusConverter = ExpressOnrampStatusToUiStatusConverter()
override fun convert(value: ExpressTx): TransactionItemUM = when (value) {
is ExpressTx.Swap -> swapContent(value)
@ -46,7 +47,7 @@ internal class ExpressTxToTransactionItemUMConverter(
}
private fun swapContent(swap: ExpressTx.Swap): TransactionItemUM.Content {
val status = swap.tx.status.toUiStatus()
val status = exchangeStatusConverter.convert(swap.tx.status)
val viewedAmount = if (swap.isOutgoing) swap.tx.fromAsset.amount else swap.tx.toAsset.amount
val counterparty = if (swap.isOutgoing) swap.tx.toAsset else swap.tx.fromAsset
val prefix = when {
@ -72,7 +73,7 @@ internal class ExpressTxToTransactionItemUMConverter(
}
private fun onrampContent(onramp: ExpressTx.Onramp): TransactionItemUM.Content {
val status = onramp.tx.status.toUiStatus()
val status = onrampStatusConverter.convert(onramp.tx.status)
val prefix = when {
status is Status.Failed -> ""
status is Status.Confirmed -> StringsSigns.PLUS
@ -107,15 +108,15 @@ internal class ExpressTxToTransactionItemUMConverter(
subtitle: ContentSubtitle,
warning: TextReference?,
): TransactionItemUM.Content {
val explorerHash = tx.matchHash ?: tx.txId
// Row identity (Compose key): the on-chain leg's hash when matched, else the express txId stands in.
return TransactionItemUM.Content(
txHash = explorerHash,
txHash = tx.explorerHash ?: tx.txId,
amount = amount,
currencySymbol = currency.symbol,
time = tx.timestampMillis.toTimeFormat(),
status = status,
direction = direction,
onClick = { txHistoryUiActions.openTxInExplorer(explorerHash) },
onClick = { txHistoryUiActions.onTransactionClick(tx) },
iconRes = iconRes,
title = title,
subtitle = subtitle,
@ -142,50 +143,4 @@ internal class ExpressTxToTransactionItemUMConverter(
wrappedList(resourceReference(R.string.tx_history_onramp_top_up)),
)
}
}
// region Status mapping
/**
* Collapses the typed swap status into a UI [Status] bucket: the single success state ([Finished][Confirmed]),
* the failure/return states ([Failed]/[TxFailed]/[Refunded]/[Expired]/[Unknown]) Failed, everything in flight
* (incl. [Verifying] and [Paused]) Unconfirmed.
*/
private fun ExpressExchangeStatus.toUiStatus(): Status = when (this) {
ExpressExchangeStatus.Finished -> Status.Confirmed
ExpressExchangeStatus.Failed,
ExpressExchangeStatus.TxFailed,
ExpressExchangeStatus.Refunded,
ExpressExchangeStatus.Expired,
ExpressExchangeStatus.Unknown,
-> Status.Failed
ExpressExchangeStatus.Preview,
ExpressExchangeStatus.Created,
ExpressExchangeStatus.ExchangeTxSent,
ExpressExchangeStatus.Waiting,
ExpressExchangeStatus.WaitingTxHash,
ExpressExchangeStatus.Confirming,
ExpressExchangeStatus.Exchanging,
ExpressExchangeStatus.Sending,
ExpressExchangeStatus.Verifying,
ExpressExchangeStatus.Paused,
-> Status.Unconfirmed
}
private fun ExpressOnrampStatus.toUiStatus(): Status = when (this) {
ExpressOnrampStatus.Finished -> Status.Confirmed
ExpressOnrampStatus.Failed,
ExpressOnrampStatus.Expired,
ExpressOnrampStatus.Unknown,
-> Status.Failed
ExpressOnrampStatus.Created,
ExpressOnrampStatus.WaitingForPayment,
ExpressOnrampStatus.PaymentProcessing,
ExpressOnrampStatus.Verifying,
ExpressOnrampStatus.Paid,
ExpressOnrampStatus.Sending,
ExpressOnrampStatus.Paused,
-> Status.Unconfirmed
}
// endregion
}

View file

@ -4,15 +4,21 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.utils.converter.Converter
/**
* Converts a merged [TxHistoryInfo] row to [TransactionItemUM]: on-chain rows convert their `TxInfo` via
* [TxHistoryItemToTransactionItemUMConverter]; express rows map directly via [ExpressTxToTransactionItemUMConverter].
* Converts a merged [TxHistoryInfo] row to [TransactionItemUM].
*
* On-chain rows render via the plain [TxHistoryItemToTransactionItemUMConverter] (which knows only `TxInfo`); since
* that converter cannot reference the merged row, the row click is bound here to the **incoming** [OnChainTx] so the
* model resolves it in the live list by [TxHistoryInfo.txId]. Express rows carry their own [TxHistoryInfo] and wire
* the click themselves in [ExpressTxToTransactionItemUMConverter].
*/
internal class TxHistoryInfoToTransactionItemUMConverter(
private val txInfoConverter: TxHistoryItemToTransactionItemUMConverter,
private val expressConverter: ExpressTxToTransactionItemUMConverter,
private val txHistoryUiActions: TxHistoryUiActions,
) : Converter<TxHistoryInfo, TransactionItemUM> {
override fun convert(value: TxHistoryInfo): TransactionItemUM = when (value) {
@ -21,6 +27,10 @@ internal class TxHistoryInfoToTransactionItemUMConverter(
}
private fun convertOnChain(value: OnChainTx): TransactionItemUM = when (value) {
is OnChainTx.BSDK -> txInfoConverter.convert(value.txInfo)
is OnChainTx.BSDK -> when (val um = txInfoConverter.convert(value.txInfo)) {
// Content rows (transfer/swap/…) route through the details/explorer decision; pills stay on the explorer.
is TransactionItemUM.Content -> um.copy(onClick = { txHistoryUiActions.onTransactionClick(value) })
else -> um
}
}
}

View file

@ -1,175 +0,0 @@
package com.tangem.features.txhistory.converter
import androidx.annotation.StringRes
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity
import com.tangem.features.txhistory.impl.R
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import com.tangem.utils.toBriefAddressFormat
import kotlinx.collections.immutable.persistentListOf
import org.joda.time.DateTime
/**
* Converts a [TxInfo] to a [TxHistoryDetailsUM] for the in-app transaction details card.
*
* Single dispatch on [TxInfo.type] picks the layout family mirroring the same `when(type)` used by
* [TxHistoryItemToTransactionItemUMConverter]:
* - [TransactionType.Swap] (and onramp once it lands in `TxInfo`) -> [TxHistoryDetailsUM.TwoAssets]
* - everything else -> [TxHistoryDetailsUM.SingleAsset]
*/
internal class TxInfoToTxHistoryDetailsUMConverter(
private val currency: CryptoCurrency,
private val onCopyAddress: (String) -> Unit,
) : Converter<TxInfo, TxHistoryDetailsUM> {
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: TxInfo): TxHistoryDetailsUM = when (value.type) {
// TODO([REDACTED_TASK_KEY]): populate `from` / `to` legs once TxInfo exposes the swap legs (amounts, currencies, fiat).
// Until then the card falls back to the header-only placeholder (the TwoAssetsBlock UI is already wired).
is TransactionType.Swap -> TxHistoryDetailsUM.TwoAssets(
header = value.toHeaderUM(),
statusBanner = value.toStatusBannerUM(),
)
else -> TxHistoryDetailsUM.SingleAsset(
header = value.toHeaderUM(),
amountBlock = value.toAmountBlockUM(),
counterparty = value.toCounterpartyUM(),
// TODO: TxInfo has no network fee / rate yet — empty until those fields are added to TxInfo.
rows = persistentListOf(),
)
}
private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM(
iconRes = headerIcon(),
status = status.toUiStatus(),
title = headerTitle(),
subtitle = headerSubtitle(),
)
/**
* Express status plaque under the swap block. A stopgap over the three generic [TxInfo.TransactionStatus] values
* so [Severity.Warning] (verification) is not reachable yet.
*
* [REDACTED_TODO_COMMENT]
*/
private fun TxInfo.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM = when (status) {
is TxInfo.TransactionStatus.Unconfirmed -> TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Info,
title = resourceReference(R.string.express_exchange_status_receiving_active),
isLoading = true,
)
is TxInfo.TransactionStatus.Confirmed -> TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Success,
title = resourceReference(R.string.express_exchange_status_exchanged),
isLoading = false,
)
is TxInfo.TransactionStatus.Failed -> TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Error,
title = resourceReference(R.string.express_exchange_status_failed),
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
isLoading = false,
)
}
private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = iconStateConverter.convert(currency),
amount = stringReference(signedAmount(currency)),
// TODO: TxInfo has no fiat amount yet — empty until the fiat field is added to TxInfo; a hardcoded
// placeholder would show a misleading value.
fiatAmount = TextReference.EMPTY,
isFailed = status is TxInfo.TransactionStatus.Failed,
)
/**
* Counterparty card ("Recipient" / "From"). Currently only the external-address avatar is produced built from
* the `User` interaction address (the same source the history list uses for its external-address subtitle).
*
* The own-account / own-wallet avatars ([TxHistoryDetailsUM.CounterpartyAvatar.Account] / `Wallet`) require the
* address->owner lookup the list assembles in `TxHistoryLookupContext`; wiring that into the detail model is a
* follow-up, so for now a counterparty that is not a plain external `User` address yields no card (`null`).
*/
private fun TxInfo.toCounterpartyUM(): TxHistoryDetailsUM.CounterpartyUM? {
val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null
return TxHistoryDetailsUM.CounterpartyUM(
label = counterpartyLabel(),
title = stringReference(address.toBriefAddressFormat()),
avatar = TxHistoryDetailsUM.CounterpartyAvatar.Address(rawAddress = address),
onCopyClick = { onCopyAddress(address) },
)
}
/** Section label above the counterparty: "Recipient" for outgoing transfers, "From" for incoming. */
private fun TxInfo.counterpartyLabel(): TextReference =
if (isOutgoing) resourceReference(R.string.send_recipient) else resourceReference(R.string.common_from)
}
// region Amount building helpers
/**
* Signed crypto amount with inline symbol, e.g. `+ 350.31 USDT` / `- 350.31 USDT`. The sign is `-` for outgoing, `+`
* otherwise, and is dropped for zero amounts and for the failed state (a failed tx moved nothing) the UI then only
* strikes the amount through and dims it via [TxHistoryDetailsUM.AmountBlockUM.isFailed].
*/
private fun TxInfo.signedAmount(currency: CryptoCurrency): String {
val formatted = amount.format { crypto(cryptoCurrency = currency, ignoreSymbolPosition = true) }
val prefix = when {
status is TxInfo.TransactionStatus.Failed -> ""
amount.isZero() -> ""
isOutgoing -> "${StringsSigns.MINUS} "
else -> "${StringsSigns.PLUS} "
}
return (prefix + formatted).trim()
}
// endregion
// region Header building helpers
/** Type glyph. Unlike the history list, the failed state keeps the type glyph (only the color changes). */
private fun TxInfo.headerIcon(): Int = when (type) {
is TransactionType.Swap -> R.drawable.ic_exchange_vertical_24
else -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24
}
private fun TxInfo.headerTitle(): TextReference = when (type) {
is TransactionType.Swap -> statusAwareTitle(R.string.common_swapping, R.string.common_swapped)
is TransactionType.Transfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred)
else -> stringReference(type.toString())
}
private fun TxInfo.headerSubtitle(): TextReference {
val dateTime = DateTime(timestampInMillis)
val date = DateTimeFormatters.dateMMMdYYYY.print(dateTime)
val time = DateTimeFormatters.timeFormatter.print(dateTime)
return stringReference("$date, $time")
}
private fun TxInfo.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (status) {
is TxInfo.TransactionStatus.Failed ->
resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending)))
is TxInfo.TransactionStatus.Unconfirmed -> resourceReference(pending)
is TxInfo.TransactionStatus.Confirmed -> resourceReference(confirmed)
}
private fun TxInfo.TransactionStatus.toUiStatus(): TransactionItemUM.Content.Status = when (this) {
TxInfo.TransactionStatus.Confirmed -> TransactionItemUM.Content.Status.Confirmed
TxInfo.TransactionStatus.Failed -> TransactionItemUM.Content.Status.Failed
TxInfo.TransactionStatus.Unconfirmed -> TransactionItemUM.Content.Status.Unconfirmed
}
// endregion

View file

@ -6,7 +6,7 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.features.txhistory.component.TxHistoryDetailsComponent
import com.tangem.features.txhistory.converter.TxInfoToTxHistoryDetailsUMConverter
import com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.SharingStarted
@ -26,12 +26,12 @@ internal class TxHistoryDetailsModel @Inject constructor(
private val params: TxHistoryDetailsComponent.Params = paramsContainer.require()
private val converter = TxInfoToTxHistoryDetailsUMConverter(
private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = params.currency,
onCopyAddress = ::onCopyAddress,
)
val uiState: StateFlow<TxHistoryDetailsUM?> = params.txInfo
val uiState: StateFlow<TxHistoryDetailsUM?> = params.txHistoryInfo
.map(converter::convert)
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.WhileSubscribed(), initialValue = null)

View file

@ -20,7 +20,11 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.domain.txhistory.model.explorerHash
import com.tangem.domain.txhistory.models.TxHistoryStateError
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
@ -211,6 +215,7 @@ internal class TxHistoryModel @Inject constructor(
currency = params.currency,
txHistoryUiActions = this,
),
txHistoryUiActions = this,
)
val items = mutableListOf<TxHistoryItemsUM.TxHistoryItemUM>()
@ -361,4 +366,20 @@ internal class TxHistoryModel @Inject constructor(
ifRight = { urlOpener.openUrl(url = it) },
)
}
override fun onTransactionClick(item: TxHistoryInfo) {
// manager is non-null only under the new tx-history toggle; on the legacy path every tap falls to the explorer.
val manager = historyTxListManager
if (manager != null && item.opensInAppDetails()) {
params.onTxDetailsRequested(manager.txHistoryInfoFlow(item))
} else {
item.explorerHash?.let(::openTxInExplorer)
}
}
}
/** On-chain transfers/swaps and every express op open the in-app details sheet; everything else goes to the explorer. */
private fun TxHistoryInfo.opensInAppDetails(): Boolean = when (this) {
is ExpressTx -> true
is OnChainTx.BSDK -> txInfo.type is TxInfo.TransactionType.Transfer || txInfo.type is TxInfo.TransactionType.Swap
}

View file

@ -120,16 +120,6 @@ internal class TxHistoryListManager(
)
}
fun txInfoFlow(txHash: String, type: TxInfo.TransactionType): Flow<TxInfo> = state
.map { st ->
st.rawBatches.asSequence()
.flatMap { it.data.items }
.firstOrNull { it.txHash == txHash && it.type == type }
}
.filterNotNull()
.distinctUntilChanged()
.flowOn(dispatchers.default)
private fun updateState(
batchListState: BatchListState<Int, PaginationWrapper<TxInfo>>,
lookupContext: TxHistoryLookupContext?,

View file

@ -1,7 +1,10 @@
package com.tangem.features.txhistory.utils
import com.tangem.domain.txhistory.model.TxHistoryInfo
internal interface TxHistoryUiActions {
fun openExplorer()
fun openTxInExplorer(txHash: String)
fun onTransactionClick(item: TxHistoryInfo)
}

View file

@ -196,21 +196,19 @@ internal class ExpressTxToTransactionItemUMConverterTest {
}
@Test
fun `GIVEN matched on-chain leg WHEN row clicked THEN opens explorer by match hash`() {
val result = converter.convert(
createSwap(status = ExpressExchangeStatus.Waiting, matchHash = "0xhash", isOutgoing = true),
) as TransactionItemUM.Content
fun `GIVEN express row WHEN row clicked THEN opens in-app details for that tx`() {
val swap = createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true)
val result = converter.convert(swap) as TransactionItemUM.Content
result.onClick()
verify { txHistoryUiActions.openTxInExplorer("0xhash") }
verify { txHistoryUiActions.onTransactionClick(swap) }
}
// endregion
private fun createSwap(
status: ExpressExchangeStatus,
matchHash: String? = null,
isOutgoing: Boolean = true,
fromAmount: BigDecimal? = BigDecimal("1.5"),
toAmount: BigDecimal? = BigDecimal("0.001"),
@ -220,8 +218,8 @@ internal class ExpressTxToTransactionItemUMConverterTest {
status = status,
createdAtMillis = 100,
provider = null,
payinHash = matchHash.takeIf { isOutgoing },
payoutHash = matchHash.takeUnless { isOutgoing },
payinHash = null,
payoutHash = null,
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
amount = fromAmount,

View file

@ -0,0 +1,131 @@
package com.tangem.features.txhistory.converter
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TxHistoryInfoToTransactionItemUMConverterTest {
private val txHistoryUiActions: TxHistoryUiActions = mockk(relaxed = true)
private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum
private val converter = TxHistoryInfoToTransactionItemUMConverter(
txInfoConverter = TxHistoryItemToTransactionItemUMConverter(
currency = currency,
txHistoryUiActions = txHistoryUiActions,
),
expressConverter = ExpressTxToTransactionItemUMConverter(
currency = currency,
txHistoryUiActions = txHistoryUiActions,
),
txHistoryUiActions = txHistoryUiActions,
)
@Test
fun `GIVEN on-chain content row WHEN row clicked THEN routes the incoming OnChainTx through onTransactionClick`() {
// Arrange
val item = OnChainTx.BSDK(txInfo(type = TransactionType.Transfer))
// Act
val result = converter.convert(item) as TransactionItemUM.Content
result.onClick()
// Assert
verify { txHistoryUiActions.onTransactionClick(item) }
}
@Test
fun `GIVEN on-chain swap row WHEN row clicked THEN routes the incoming OnChainTx through onTransactionClick`() {
// Arrange
val item = OnChainTx.BSDK(txInfo(type = TransactionType.Swap))
// Act
val result = converter.convert(item) as TransactionItemUM.Content
result.onClick()
// Assert
verify { txHistoryUiActions.onTransactionClick(item) }
}
@Test
fun `GIVEN on-chain pill row WHEN row clicked THEN stays on the explorer`() {
// Arrange
val item = OnChainTx.BSDK(txInfo(type = TransactionType.Approve))
// Act
val result = converter.convert(item) as TransactionItemUM.Pill
result.onClick()
// Assert
verify { txHistoryUiActions.openTxInExplorer(TX_HASH) }
}
@Test
fun `GIVEN express row WHEN row clicked THEN routes the incoming ExpressTx through onTransactionClick`() {
// Arrange
val item = expressSwap()
// Act
val result = converter.convert(item) as TransactionItemUM.Content
result.onClick()
// Assert
verify { txHistoryUiActions.onTransactionClick(item) }
}
private fun txInfo(type: TransactionType): TxInfo = TxInfo(
txHash = TX_HASH,
timestampInMillis = TIMESTAMP,
isOutgoing = false,
destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)),
sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS),
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
status = TxInfo.TransactionStatus.Confirmed,
type = type,
amount = BigDecimal.ONE,
)
private fun expressSwap(): ExpressTx.Swap = ExpressTx.Swap(
tx = ExchangeTransaction(
txId = "swap-1",
status = ExpressExchangeStatus.Exchanging,
createdAtMillis = TIMESTAMP,
provider = null,
payinHash = null,
payoutHash = null,
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "ethereum", contractAddress = "0"),
amount = BigDecimal("1.5"),
decimals = 18,
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "bitcoin", contractAddress = "0"),
amount = BigDecimal("0.001"),
decimals = 8,
),
),
isOutgoing = true,
txInfo = null,
)
private companion object {
const val TX_HASH = "0xtxhash"
const val TIMESTAMP = 1_700_000_000_000L
const val USER_ADDRESS = "0x1234567890abcdef1234"
}
}

View file

@ -6,10 +6,21 @@ import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.impl.R
import com.tangem.test.core.ProvideTestModels
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
@ -17,14 +28,15 @@ import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TxInfoToTxHistoryDetailsUMConverterTest {
internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
private val currency = MockCryptoCurrencyFactory().ethereum
private val copiedAddresses = mutableListOf<String>()
private val converter = TxInfoToTxHistoryDetailsUMConverter(
private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = currency,
onCopyAddress = copiedAddresses::add,
)
@ -42,44 +54,49 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
unmockkStatic(DateFormat::class)
}
// region On-chain (TxInfo)
@Test
fun `GIVEN Swap WHEN convert THEN TwoAssets`() {
fun `GIVEN on-chain Swap WHEN convert THEN SingleAsset fallback`() {
// A two-asset swap always surfaces as ExpressTx.Swap; an on-chain TxInfo of type Swap has no legs,
// so it falls back to the single amount it does carry rather than an empty two-asset card.
// Arrange
val tx = txInfo(type = TransactionType.Swap)
val tx = onChain(type = TransactionType.Swap)
// Act
val result = converter.convert(tx)
// Assert
assertThat(result).isInstanceOf(TxHistoryDetailsUM.TwoAssets::class.java)
assertThat(result).isInstanceOf(TxHistoryDetailsUM.SingleAsset::class.java)
}
@Test
fun `GIVEN non-Swap TransactionType WHEN convert THEN SingleAsset`() {
val nonSwapTypes = listOf(
TransactionType.Transfer,
TransactionType.Approve,
TransactionType.Operation(name = "Mint NFT"),
TransactionType.UnknownOperation,
TransactionType.GaslessFee,
TransactionType.Staking.Stake,
TransactionType.Staking.ClaimRewards,
TransactionType.Staking.Vote(validatorAddress = VALIDATOR_ADDRESS),
TransactionType.YieldSupply.Topup,
TransactionType.YieldSupply.Enter(address = USER_ADDRESS),
)
@ParameterizedTest
@ProvideTestModels
fun `GIVEN non-Swap TransactionType WHEN convert THEN SingleAsset`(type: TransactionType) {
// Act
val result = converter.convert(onChain(type = type))
nonSwapTypes.forEach { type ->
val result = converter.convert(txInfo(type = type))
assertThat(result).isInstanceOf(TxHistoryDetailsUM.SingleAsset::class.java)
}
// Assert
assertThat(result).isInstanceOf(TxHistoryDetailsUM.SingleAsset::class.java)
}
private fun provideTestModels() = listOf(
TransactionType.Transfer,
TransactionType.Approve,
TransactionType.Operation(name = "Mint NFT"),
TransactionType.UnknownOperation,
TransactionType.GaslessFee,
TransactionType.Staking.Stake,
TransactionType.Staking.ClaimRewards,
TransactionType.Staking.Vote(validatorAddress = VALIDATOR_ADDRESS),
TransactionType.YieldSupply.Topup,
TransactionType.YieldSupply.Enter(address = USER_ADDRESS),
)
@Test
fun `GIVEN incoming confirmed Transfer WHEN convert THEN header has down icon, confirmed status, transferred title`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer)
val tx = onChain(type = TransactionType.Transfer)
// Act
val header = converter.convert(tx).header
@ -93,7 +110,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
@Test
fun `GIVEN Swap WHEN convert THEN header has exchange icon`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap)
val tx = onChain(type = TransactionType.Swap)
// Act
val header = converter.convert(tx).header
@ -102,65 +119,10 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
assertThat(header.iconRes).isEqualTo(R.drawable.ic_exchange_vertical_24)
}
@Test
fun `GIVEN unconfirmed Swap WHEN convert THEN info status banner with loader`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Unconfirmed)
// Act
val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info,
title = resourceReference(R.string.express_exchange_status_receiving_active),
isLoading = true,
),
)
}
@Test
fun `GIVEN confirmed Swap WHEN convert THEN success status banner without loader`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Confirmed)
// Act
val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success,
title = resourceReference(R.string.express_exchange_status_exchanged),
isLoading = false,
),
)
}
@Test
fun `GIVEN failed Swap WHEN convert THEN error status banner with refund subtitle`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Failed)
// Act
val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error,
title = resourceReference(R.string.express_exchange_status_failed),
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
isLoading = false,
),
)
}
@Test
fun `GIVEN incoming Transfer WHEN convert THEN amount block has plus sign and not failed`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer, isOutgoing = false)
val tx = onChain(type = TransactionType.Transfer, isOutgoing = false)
// Act
val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock
@ -173,7 +135,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
@Test
fun `GIVEN outgoing Transfer WHEN convert THEN amount block has minus sign`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer, isOutgoing = true)
val tx = onChain(type = TransactionType.Transfer, isOutgoing = true)
// Act
val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock
@ -185,7 +147,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
@Test
fun `GIVEN zero amount WHEN convert THEN amount block has no sign`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer, isOutgoing = true, amount = BigDecimal.ZERO)
val tx = onChain(type = TransactionType.Transfer, isOutgoing = true, amount = BigDecimal.ZERO)
// Act
val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock
@ -199,7 +161,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
@Test
fun `GIVEN failed outgoing Transfer WHEN convert THEN amount block is failed and drops the sign`() {
// Arrange
val tx = txInfo(
val tx = onChain(
type = TransactionType.Transfer,
isOutgoing = true,
status = TxInfo.TransactionStatus.Failed,
@ -218,7 +180,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
@Test
fun `GIVEN no interaction address WHEN convert THEN counterparty is null`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer, interactionAddressType = null)
val tx = onChain(type = TransactionType.Transfer, interactionAddressType = null)
// Act
val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).counterparty
@ -230,7 +192,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
@Test
fun `GIVEN incoming Transfer with User address WHEN convert THEN address-avatar counterparty with From label`() {
// Arrange
val tx = txInfo(
val tx = onChain(
type = TransactionType.Transfer,
isOutgoing = false,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
@ -247,7 +209,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
@Test
fun `GIVEN outgoing Transfer with User address WHEN convert THEN counterparty has Recipient label`() {
// Arrange
val tx = txInfo(
val tx = onChain(
type = TransactionType.Transfer,
isOutgoing = true,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
@ -263,7 +225,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
@Test
fun `GIVEN address counterparty WHEN onCopyClick invoked THEN raw address is copied`() {
// Arrange
val tx = txInfo(
val tx = onChain(
type = TransactionType.Transfer,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
@ -276,24 +238,131 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
assertThat(copiedAddresses).containsExactly(USER_ADDRESS)
}
private fun txInfo(
// endregion
// region Express (swap / onramp)
@Test
fun `GIVEN express swap WHEN convert THEN TwoAssets with exchange icon`() {
// Act
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging))
// Assert
assertThat(result).isInstanceOf(TxHistoryDetailsUM.TwoAssets::class.java)
assertThat(result.header.iconRes).isEqualTo(R.drawable.ic_exchange_vertical_24)
}
@Test
fun `GIVEN in-progress express swap WHEN convert THEN info status banner with loader`() {
// Act
val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging))
val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info,
title = resourceReference(R.string.express_exchange_status_receiving_active),
isLoading = true,
),
)
}
@Test
fun `GIVEN failed express swap WHEN convert THEN error status banner with refund subtitle`() {
// Act
val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Failed))
val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error,
title = resourceReference(R.string.express_exchange_status_failed),
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
isLoading = false,
),
)
}
@Test
fun `GIVEN finished express onramp WHEN convert THEN TwoAssets with success banner`() {
// Act
val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Finished)) as TxHistoryDetailsUM.TwoAssets
// Assert
assertThat(result.header.iconRes).isEqualTo(R.drawable.ic_tangem_card_24)
assertThat(result.statusBanner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success,
title = resourceReference(R.string.express_exchange_status_exchanged),
isLoading = false,
),
)
}
// endregion
private fun onChain(
type: TransactionType,
isOutgoing: Boolean = false,
status: TxInfo.TransactionStatus = TxInfo.TransactionStatus.Confirmed,
amount: BigDecimal = BigDecimal.ONE,
interactionAddressType: TxInfo.InteractionAddressType? = null,
): TxInfo = TxInfo(
txHash = TX_HASH,
timestampInMillis = TIMESTAMP,
isOutgoing = isOutgoing,
destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)),
sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS),
interactionAddressType = interactionAddressType,
status = status,
type = type,
amount = amount,
): OnChainTx.BSDK = OnChainTx.BSDK(
TxInfo(
txHash = TX_HASH,
timestampInMillis = TIMESTAMP,
isOutgoing = isOutgoing,
destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)),
sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS),
interactionAddressType = interactionAddressType,
status = status,
type = type,
amount = amount,
),
)
private fun expressSwap(status: ExpressExchangeStatus): ExpressTx.Swap = ExpressTx.Swap(
tx = ExchangeTransaction(
txId = "swap-1",
status = status,
createdAtMillis = TIMESTAMP,
provider = null,
payinHash = null,
payoutHash = null,
fromAsset = expressAsset(networkId = "ethereum", amount = BigDecimal("1.5"), decimals = 18),
toAsset = expressAsset(networkId = "bitcoin", amount = BigDecimal("0.001"), decimals = 8),
),
isOutgoing = true,
txInfo = null,
)
private fun expressOnramp(status: ExpressOnrampStatus): ExpressTx.Onramp = ExpressTx.Onramp(
tx = OnrampTransaction(
txId = "onramp-1",
status = status,
createdAtMillis = TIMESTAMP,
provider = null,
payoutHash = null,
fromFiat = Amount(
currencySymbol = "SEK",
value = BigDecimal("100"),
decimals = 2,
type = AmountType.FiatType(code = "SEK"),
),
toAsset = expressAsset(networkId = "bitcoin", amount = BigDecimal("0.006"), decimals = 8),
),
txInfo = null,
)
private fun expressAsset(networkId: String, amount: BigDecimal, decimals: Int): ExpressTransactionAsset =
ExpressTransactionAsset(
id = ExpressAssetId(networkId = networkId, contractAddress = "0"),
amount = amount,
decimals = decimals,
)
private fun TextReference.resolveString(): String = (this as TextReference.Str).value
private companion object {