Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-29 17:22:09 +03:00
parent 3503cadb5e
commit b91ed6359c
12 changed files with 305 additions and 97 deletions

View file

@ -53,6 +53,7 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
createdAtMillis = parseIsoMillis(entity.createdAt), createdAtMillis = parseIsoMillis(entity.createdAt),
provider = value.provider, provider = value.provider,
payoutHash = entity.payoutHash, payoutHash = entity.payoutHash,
payoutAddress = entity.payoutAddress,
fromFiat = Amount( fromFiat = Amount(
currencySymbol = entity.fromCurrencyCode, currencySymbol = entity.fromCurrencyCode,
value = entity.fromAmount.toScaledBigDecimal(entity.fromPrecision), value = entity.fromAmount.toScaledBigDecimal(entity.fromPrecision),
@ -89,6 +90,8 @@ private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): Excha
provider = value.provider, provider = value.provider,
payinHash = entity.payinHash, payinHash = entity.payinHash,
payoutHash = entity.payoutHash, payoutHash = entity.payoutHash,
fromAddress = entity.fromAddress,
payoutAddress = entity.payoutAddress,
fromAsset = ExpressTransactionAsset( fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.from.network, contractAddress = entity.from.contractAddress), id = ExpressAssetId(networkId = entity.from.network, contractAddress = entity.from.contractAddress),
amount = entity.from.amount.toScaledBigDecimal(entity.from.decimals), amount = entity.from.amount.toScaledBigDecimal(entity.from.decimals),

View file

@ -11,6 +11,10 @@ package com.tangem.domain.express.models
* @property provider The provider behind the deal; `null` if not resolved. * @property provider The provider behind the deal; `null` if not resolved.
* @property payinHash On-chain hash of the pay-in (from-side) leg, if known. * @property payinHash On-chain hash of the pay-in (from-side) leg, if known.
* @property payoutHash On-chain hash of the payout (to-side) leg, if known. * @property payoutHash On-chain hash of the payout (to-side) leg, if known.
* @property fromAddress Address the `from` assets were taken from (the user's own source address); `null` when unknown
* (very old app versions did not send it).
* @property payoutAddress Address that received the `to` assets the user's own address for a regular swap, an external
* one for a send-and-swap; `null` when unknown.
* @property fromAsset The asset sent. * @property fromAsset The asset sent.
* @property toAsset The asset received. * @property toAsset The asset received.
* @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider * @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
@ -23,6 +27,8 @@ data class ExchangeTransaction(
val provider: ExpressProvider?, val provider: ExpressProvider?,
val payinHash: String?, val payinHash: String?,
val payoutHash: String?, val payoutHash: String?,
val fromAddress: String?,
val payoutAddress: String?,
val fromAsset: ExpressTransactionAsset, val fromAsset: ExpressTransactionAsset,
val toAsset: ExpressTransactionAsset, val toAsset: ExpressTransactionAsset,
val externalTxUrl: String? = null, val externalTxUrl: String? = null,

View file

@ -14,6 +14,7 @@ import com.tangem.domain.tokens.model.AmountType
* @property provider The provider behind the deal; `null` if not resolved. * @property provider The provider behind the deal; `null` if not resolved.
* @property payoutHash On-chain hash of the payout (received) leg, if known. * @property payoutHash On-chain hash of the payout (received) leg, if known.
* @property payoutAddress Address that received the crypto (the user's own address); `null` when unknown.
* @property fromFiat The fiat paid. * @property fromFiat The fiat paid.
* @property toAsset The crypto asset received. * @property toAsset The crypto asset received.
* @property country The country the onramp was made from; `null` if not resolved. * @property country The country the onramp was made from; `null` if not resolved.
@ -26,6 +27,7 @@ data class OnrampTransaction(
val createdAtMillis: Long, val createdAtMillis: Long,
val provider: ExpressProvider?, val provider: ExpressProvider?,
val payoutHash: String?, val payoutHash: String?,
val payoutAddress: String?,
/** The [Amount.type] is [AmountType.FiatType] . */ /** The [Amount.type] is [AmountType.FiatType] . */
val fromFiat: Amount, val fromFiat: Amount,
val toAsset: ExpressTransactionAsset, val toAsset: ExpressTransactionAsset,

View file

@ -1,6 +1,9 @@
package com.tangem.features.txhistory.converter package com.tangem.features.txhistory.converter
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.tangem.common.ui.account.getResId
import com.tangem.common.ui.account.getUiColor
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
@ -28,6 +31,9 @@ import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity
import com.tangem.features.txhistory.impl.R import com.tangem.features.txhistory.impl.R
import com.tangem.features.txhistory.model.ResolvedOwner
import com.tangem.features.txhistory.model.TxHistoryLookupContext
import com.tangem.features.txhistory.model.resolveOwner
import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero import com.tangem.utils.extensions.isZero
@ -51,13 +57,21 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
private val currency: CryptoCurrency, private val currency: CryptoCurrency,
private val onCopyAddress: (String) -> Unit, private val onCopyAddress: (String) -> Unit,
private val onGoToProvider: (String) -> Unit, private val onGoToProvider: (String) -> Unit,
private val ownAddresses: Set<String> = emptySet(), private val lookup: TxHistoryLookupContext = TxHistoryLookupContext(
ownAccountByNetwork = emptyMap(),
isAccountsModeEnabled = false,
walletInfoById = emptyMap(),
),
) : Converter<TxHistoryInfo, TxHistoryDetailsUM> { ) : Converter<TxHistoryInfo, TxHistoryDetailsUM> {
private val iconStateConverter = CryptoCurrencyToIconStateConverter() private val iconStateConverter = CryptoCurrencyToIconStateConverter()
private val exchangeStatusConverter = ExpressExchangeStatusToUiStatusConverter() private val exchangeStatusConverter = ExpressExchangeStatusToUiStatusConverter()
private val onrampStatusConverter = ExpressOnrampStatusToUiStatusConverter() private val onrampStatusConverter = ExpressOnrampStatusToUiStatusConverter()
/** Own deposit addresses on the viewed currency's network — drives the on-chain own-vs-external transfer title. */
private val ownAddresses: Set<String> =
lookup.ownAccountByNetwork[currency.network.id.rawId]?.keys.orEmpty()
override fun convert(value: TxHistoryInfo): TxHistoryDetailsUM = when (value) { override fun convert(value: TxHistoryInfo): TxHistoryDetailsUM = when (value) {
is OnChainTx.BSDK -> convertOnChain(value.txInfo) is OnChainTx.BSDK -> convertOnChain(value.txInfo)
is ExpressTx.Swap -> convertExpressSwap(value) is ExpressTx.Swap -> convertExpressSwap(value)
@ -93,12 +107,13 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
) )
/** /**
* Counterparty card ("Recipient" / "From"). Currently only the external-address avatar is produced built from * Counterparty card ("Recipient" / "From"). Only the external-address avatar is produced built from the `User`
* the `User` interaction address (the same source the history list uses for its external-address subtitle). * interaction address (the same source the history list uses for its external-address subtitle); a counterparty that
* is not a plain external `User` address yields no card (`null`).
* *
* The own-account / own-wallet avatars require the address->owner lookup the list assembles in * The [lookup] needed to resolve an own-account / own-wallet avatar here is already available (it drives the
* `TxHistoryLookupContext`; wiring that into the detail model is a follow-up, so for now a counterparty that is not * swap/onramp leg owners), but applying it to the single-asset counterparty card is intentionally out of scope for
* a plain external `User` address yields no card (`null`). * now a follow-up.
*/ */
private fun TxInfo.toCounterpartyUM(): TxHistoryDetailsUM.CounterpartyUM? { private fun TxInfo.toCounterpartyUM(): TxHistoryDetailsUM.CounterpartyUM? {
val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null
@ -144,6 +159,8 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
*/ */
private fun convertExpressSwap(swap: ExpressTx.Swap): TxHistoryDetailsUM.TwoAssets { private fun convertExpressSwap(swap: ExpressTx.Swap): TxHistoryDetailsUM.TwoAssets {
val status = exchangeStatusConverter.convert(swap.tx.status) val status = exchangeStatusConverter.convert(swap.tx.status)
val fromOwner = resolveLegOwner(swap.tx.fromAddress, swap.tx.fromAsset.cryptoCurrency)
val toOwner = resolveLegOwner(swap.tx.payoutAddress, swap.tx.toAsset.cryptoCurrency)
return TxHistoryDetailsUM.TwoAssets( return TxHistoryDetailsUM.TwoAssets(
header = TxHistoryDetailsUM.HeaderUM( header = TxHistoryDetailsUM.HeaderUM(
iconRes = R.drawable.ic_exchange_vertical_24, iconRes = R.drawable.ic_exchange_vertical_24,
@ -152,12 +169,14 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
subtitle = headerSubtitle(swap.timestampMillis), subtitle = headerSubtitle(swap.timestampMillis),
), ),
from = swap.tx.fromAsset.toAssetUM( from = swap.tx.fromAsset.toAssetUM(
label = resourceReference(R.string.swapping_from_title_v2), label = ownerLabel(fromOwner, fallback = R.string.swapping_from_title_v2, owned = R.string.common_from),
owner = fromOwner,
sign = status.outgoingSign(), sign = status.outgoingSign(),
isFaded = status is Status.Failed, isFaded = status is Status.Failed,
), ),
to = swap.tx.toAsset.toAssetUM( to = swap.tx.toAsset.toAssetUM(
label = resourceReference(R.string.swapping_to_title), label = ownerLabel(toOwner, fallback = R.string.swapping_to_title, owned = R.string.common_to),
owner = toOwner,
sign = status.incomingSign(), sign = status.incomingSign(),
isFaded = status is Status.Failed, isFaded = status is Status.Failed,
), ),
@ -169,6 +188,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
private fun convertExpressOnramp(onramp: ExpressTx.Onramp): TxHistoryDetailsUM.TwoAssets { private fun convertExpressOnramp(onramp: ExpressTx.Onramp): TxHistoryDetailsUM.TwoAssets {
val status = onrampStatusConverter.convert(onramp.tx.status) val status = onrampStatusConverter.convert(onramp.tx.status)
val toOwner = resolveLegOwner(onramp.tx.payoutAddress, onramp.tx.toAsset.cryptoCurrency)
return TxHistoryDetailsUM.TwoAssets( return TxHistoryDetailsUM.TwoAssets(
header = TxHistoryDetailsUM.HeaderUM( header = TxHistoryDetailsUM.HeaderUM(
iconRes = R.drawable.ic_tangem_card_24, iconRes = R.drawable.ic_tangem_card_24,
@ -180,11 +200,13 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
subtitle = headerSubtitle(onramp.timestampMillis), subtitle = headerSubtitle(onramp.timestampMillis),
), ),
from = onramp.tx.fromFiat.toFiatAssetUM( from = onramp.tx.fromFiat.toFiatAssetUM(
label = resourceReference(R.string.swapping_from_title_v2), // The fiat side was paid from a card, not a portfolio address — no owner to resolve.
label = resourceReference(R.string.tx_history_you_paid),
isFaded = status is Status.Failed, isFaded = status is Status.Failed,
), ),
to = onramp.tx.toAsset.toAssetUM( to = onramp.tx.toAsset.toAssetUM(
label = resourceReference(R.string.swapping_to_title), label = ownerLabel(toOwner, fallback = R.string.swapping_to_title, owned = R.string.common_to),
owner = toOwner,
sign = status.incomingSign(), sign = status.incomingSign(),
isFaded = status is Status.Failed, isFaded = status is Status.Failed,
), ),
@ -194,6 +216,37 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
) )
} }
/**
* Resolves a swap/onramp leg's [address] (on the leg currency's network) to the owner shown under the amount:
* the user's own account / wallet, or the external [TxHistoryDetailsUM.AssetOwnerUM.Address] (e.g. a send-and-swap
* payout). `null` when there is no address to resolve (e.g. the very-old-version missing `fromAddress`, onramp fiat).
*/
private fun resolveLegOwner(address: String?, legCurrency: CryptoCurrency?): TxHistoryDetailsUM.AssetOwnerUM? {
if (address == null) return null
return when (val resolved = lookup.resolveOwner(address, legCurrency?.network?.id?.rawId)) {
is ResolvedOwner.OwnAccount -> TxHistoryDetailsUM.AssetOwnerUM.Account(
name = resolved.account.accountName.toUM().value,
iconResId = resolved.account.icon.value.getResId(),
backgroundColor = resolved.account.icon.color.getUiColor(),
)
is ResolvedOwner.OwnWallet -> TxHistoryDetailsUM.AssetOwnerUM.Wallet(
name = stringReference(resolved.walletInfo.name),
deviceIconUM = resolved.walletInfo.deviceIconUM,
)
is ResolvedOwner.External -> TxHistoryDetailsUM.AssetOwnerUM.Address(
name = stringReference(resolved.address.toBriefAddressFormat()),
rawAddress = resolved.address,
)
}
}
/** Leg caption: the direction-only [fallback] ("You send" / "You receive") without an owner, "From" / "To" with one. */
private fun ownerLabel(
owner: TxHistoryDetailsUM.AssetOwnerUM?,
@StringRes fallback: Int,
@StringRes owned: Int,
): TextReference = resourceReference(if (owner != null) owned else fallback)
/** Opens the deal's provider page on tap; `null` when the deal has no provider link. */ /** Opens the deal's provider page on tap; `null` when the deal has no provider link. */
private fun ExpressTx.providerClick(): (() -> Unit)? = externalTxUrl?.let { url -> { onGoToProvider(url) } } private fun ExpressTx.providerClick(): (() -> Unit)? = externalTxUrl?.let { url -> { onGoToProvider(url) } }
@ -212,6 +265,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
*/ */
private fun ExpressTransactionAsset.toAssetUM( private fun ExpressTransactionAsset.toAssetUM(
label: TextReference, label: TextReference,
owner: TxHistoryDetailsUM.AssetOwnerUM?,
sign: String, sign: String,
isFaded: Boolean, isFaded: Boolean,
): TxHistoryDetailsUM.AssetUM { ): TxHistoryDetailsUM.AssetUM {
@ -223,7 +277,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
) }.trim() ) }.trim()
return TxHistoryDetailsUM.AssetUM( return TxHistoryDetailsUM.AssetUM(
label = label, label = label,
owner = null, owner = owner,
amount = stringReference((sign + formatted).trim()), amount = stringReference((sign + formatted).trim()),
currencyIcon = cryptoCurrency?.let(iconStateConverter::convert), currencyIcon = cryptoCurrency?.let(iconStateConverter::convert),
isFaded = isFaded, isFaded = isFaded,

View file

@ -14,11 +14,14 @@ import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.toTimeFormat import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.features.txhistory.impl.R import com.tangem.features.txhistory.impl.R
import com.tangem.features.txhistory.converter.TxHistoryStatusPillConverter.Input as PillInput import com.tangem.features.txhistory.converter.TxHistoryStatusPillConverter.Input as PillInput
import com.tangem.features.txhistory.model.ResolvedOwner
import com.tangem.features.txhistory.model.TxHistoryLookupContext import com.tangem.features.txhistory.model.TxHistoryLookupContext
import com.tangem.features.txhistory.model.resolveOwner
import com.tangem.features.txhistory.utils.TxHistoryUiActions import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter import com.tangem.utils.converter.Converter
@ -98,7 +101,14 @@ internal class TxHistoryItemToTransactionItemUMConverter(
private fun transferContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content { private fun transferContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content {
val counterpartyAddress = (tx.interactionAddressType as? TxInfo.InteractionAddressType.User)?.address val counterpartyAddress = (tx.interactionAddressType as? TxInfo.InteractionAddressType.User)?.address
val direction = if (tx.isOutgoing) ContentSubtitle.Direction.TO else ContentSubtitle.Direction.FROM val direction = if (tx.isOutgoing) ContentSubtitle.Direction.TO else ContentSubtitle.Direction.FROM
val ownSubtitle = counterpartyAddress?.let { resolveOwnSubtitle(lookupContext, it, direction) } val ownSubtitle = counterpartyAddress?.let { address ->
resolveOwnSubtitle(
lookupContext = lookupContext,
networkRawId = currency.network.id.rawId,
address = address,
direction = direction,
)
}
val title = when { val title = when {
ownSubtitle != null -> tx.statusAwareTitle(R.string.common_transfer, R.string.common_transferred) ownSubtitle != null -> tx.statusAwareTitle(R.string.common_transfer, R.string.common_transferred)
@ -261,25 +271,25 @@ private fun TxInfo.formatContentAmount(currency: CryptoCurrency): String {
private fun resolveOwnSubtitle( private fun resolveOwnSubtitle(
lookupContext: TxHistoryLookupContext?, lookupContext: TxHistoryLookupContext?,
networkRawId: Network.RawID,
address: String, address: String,
direction: ContentSubtitle.Direction, direction: ContentSubtitle.Direction,
): ContentSubtitle? { ): ContentSubtitle? {
val ctx = lookupContext ?: return null val ctx = lookupContext ?: return null
val account = ctx.ownAccountByAddress[address] ?: return null return when (val resolved = ctx.resolveOwner(address, networkRawId)) {
return if (ctx.isAccountsModeEnabled) { is ResolvedOwner.OwnAccount -> ContentSubtitle.OwnAccount(
ContentSubtitle.OwnAccount(
direction = direction, direction = direction,
accountName = account.accountName.toUM().value, accountName = resolved.account.accountName.toUM().value,
iconResId = account.icon.value.getResId(), iconResId = resolved.account.icon.value.getResId(),
iconBackgroundColor = account.icon.color.getUiColor(), iconBackgroundColor = resolved.account.icon.color.getUiColor(),
) )
} else { is ResolvedOwner.OwnWallet -> ContentSubtitle.OwnWallet(
val walletInfo = ctx.walletInfoById[account.accountId.userWalletId] ?: return null
ContentSubtitle.OwnWallet(
direction = direction, direction = direction,
walletName = walletInfo.name, walletName = resolved.walletInfo.name,
deviceIconUM = walletInfo.deviceIconUM, deviceIconUM = resolved.walletInfo.deviceIconUM,
) )
// External counterparty: caller falls back to ContentSubtitle.ExternalAddress.
is ResolvedOwner.External -> null
} }
} }

View file

@ -102,9 +102,9 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
) )
/** /**
* Counterparty rendered inline in an [AssetUM.label] when a swap leg resolves to one of the user's own portfolios. * Counterparty rendered inline in an [AssetUM.label] of a swap leg. Carries the [name] plus a kind-specific 16dp
* Carries the [name] plus a kind-specific 16dp decoration. Only own account / own wallet are decorated here (no * decoration: an own account / own wallet when the leg's address resolves to one of the user's portfolios, or an
* address case, unlike the single-asset [CounterpartyAvatar]). * external blockchain [Address] (e.g. a send-and-swap payout to a non-user address).
*/ */
@Immutable @Immutable
sealed interface AssetOwnerUM { sealed interface AssetOwnerUM {
@ -123,6 +123,15 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
override val name: TextReference, override val name: TextReference,
val deviceIconUM: DeviceIconUM, val deviceIconUM: DeviceIconUM,
) : AssetOwnerUM ) : AssetOwnerUM
/**
* External blockchain address the [name] is the brief address, decorated with an identicon generated from
* [rawAddress], shown **before** the [name].
*/
data class Address(
override val name: TextReference,
val rawAddress: String,
) : AssetOwnerUM
} }
/** /**
@ -164,9 +173,8 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
* *
* The layout is identical across counterparty kinds; the only variance is the [avatar] (see [CounterpartyAvatar]) * The layout is identical across counterparty kinds; the only variance is the [avatar] (see [CounterpartyAvatar])
* and whether copy is offered. Only the [CounterpartyAvatar.Address] kind is currently produced by * and whether copy is offered. Only the [CounterpartyAvatar.Address] kind is currently produced by
* [com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter]; the own-account / own-wallet * [com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter]; resolving the own-account /
* avatars are populated in a follow-up, once the detail model assembles the same address->owner lookup the list * own-wallet avatars here (the `TxHistoryLookupContext` is already wired for the swap/onramp legs) is a follow-up.
* uses (`TxHistoryLookupContext`).
* *
* @property label Section label above the counterparty: "Recipient" (outgoing) / "From" (incoming). * @property label Section label above the counterparty: "Recipient" (outgoing) / "From" (incoming).
* @property title Counterparty value: brief address / account name / wallet name. * @property title Counterparty value: brief address / account name / wallet name.

View file

@ -6,18 +6,14 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.features.txhistory.component.TxHistoryDetailsComponent import com.tangem.features.txhistory.component.TxHistoryDetailsComponent
import com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter import com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
@ -27,26 +23,21 @@ internal class TxHistoryDetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val clipboardManager: ClipboardManager, private val clipboardManager: ClipboardManager,
private val urlOpener: UrlOpener, private val urlOpener: UrlOpener,
multiAccountStatusListSupplier: MultiAccountStatusListSupplier, ownerLookupProducer: TxHistoryOwnerLookupProducer,
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
) : Model() { ) : Model() {
private val params: TxHistoryDetailsComponent.Params = paramsContainer.require() private val params: TxHistoryDetailsComponent.Params = paramsContainer.require()
/** Own deposit addresses for the viewed currency's network — drives the own-vs-external transfer title. */
private val ownAddressesFlow: Flow<Set<String>> = multiAccountStatusListSupplier()
.map { lists -> buildOwnAccountAddressMap(lists, params.currency.network.id.rawId).keys }
.distinctUntilChanged()
val uiState: StateFlow<TxHistoryDetailsUM?> = combine( val uiState: StateFlow<TxHistoryDetailsUM?> = combine(
params.txHistoryInfo, params.txHistoryInfo,
ownAddressesFlow, ownerLookupProducer(),
) { txInfo, ownAddresses -> ) { txInfo, lookup ->
TxHistoryInfoToTxHistoryDetailsUMConverter( TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = params.currency, currency = params.currency,
onCopyAddress = ::onCopyAddress, onCopyAddress = ::onCopyAddress,
onGoToProvider = urlOpener::openUrl, onGoToProvider = urlOpener::openUrl,
ownAddresses = ownAddresses, lookup = lookup,
).convert(txInfo) ).convert(txInfo)
} }
.flowOn(dispatchers.default) .flowOn(dispatchers.default)

View file

@ -8,14 +8,15 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
/** /**
* Per-page lookup context for the tx-history converter. * Lookup context for resolving a transfer/swap counterparty to one of the user's own portfolios, shared by the history
* list and the details screen (both assembled by [TxHistoryOwnerLookupProducer]).
* *
* - [ownAccountByAddress] / [walletInfoById] address-keyed lookups for resolving counterparty owners * - [ownAccountByNetwork] / [walletInfoById] `address -> account` maps per network (a swap's legs can sit on
* in transfer subtitles ("to / from MY account / wallet"). * different networks) plus per-wallet display info, used to render "to / from MY account / wallet".
* - [isAccountsModeEnabled] toggles whether a resolved owner is rendered as account or wallet. * - [isAccountsModeEnabled] toggles whether a resolved owner is rendered as account or wallet.
*/ */
internal data class TxHistoryLookupContext( internal data class TxHistoryLookupContext(
val ownAccountByAddress: Map<String, Account.CryptoPortfolio>, val ownAccountByNetwork: Map<Network.RawID, Map<String, Account.CryptoPortfolio>>,
val isAccountsModeEnabled: Boolean, val isAccountsModeEnabled: Boolean,
val walletInfoById: Map<UserWalletId, WalletInfo>, val walletInfoById: Map<UserWalletId, WalletInfo>,
) )
@ -23,25 +24,62 @@ internal data class TxHistoryLookupContext(
internal data class WalletInfo(val name: String, val deviceIconUM: DeviceIconUM) internal data class WalletInfo(val name: String, val deviceIconUM: DeviceIconUM)
/** /**
* Flattens every crypto-portfolio account of every wallet into an `address -> account` map for the network identified * The owner that a transfer counterparty resolves to, before it is mapped to a UI model. Shared by the history list
* by [networkRawId]. Shared by the history list and the details screen to decide whether a transfer counterparty is one * (subtitle) and the details screen (leg owner) so both apply the same precedence:
* of the user's own accounts/wallets. * account (accounts mode on) wallet (accounts mode off) external address.
*/ */
internal fun buildOwnAccountAddressMap( internal sealed interface ResolvedOwner {
data class OwnAccount(val account: Account.CryptoPortfolio) : ResolvedOwner
data class OwnWallet(val walletInfo: WalletInfo) : ResolvedOwner
data class External(val address: String) : ResolvedOwner
}
/**
* Resolves a counterparty [address] on the network [networkRawId] to a [ResolvedOwner]: the owning account in accounts
* mode, otherwise the owning wallet, falling back to the external address when it is none of the user's (or accounts
* mode is off and the wallet info is missing).
*
* [networkRawId] `null` (an unresolved express leg whose `cryptoCurrency` is missing) falls back to a cross-network
* lookup: the address is matched across every network and accepted only when it maps to exactly one account (EVM-family
* addresses repeat across chains but stay within one account; a tie across distinct accounts stays external).
*/
internal fun TxHistoryLookupContext.resolveOwner(address: String, networkRawId: Network.RawID?): ResolvedOwner {
val account = if (networkRawId != null) {
ownAccountByNetwork[networkRawId]?.get(address)
} else {
ownAccountByNetwork.values
.mapNotNull { it[address] }
.distinctBy { it.accountId }
.singleOrNull()
}
return when {
account == null -> ResolvedOwner.External(address)
isAccountsModeEnabled -> ResolvedOwner.OwnAccount(account)
else -> walletInfoById[account.accountId.userWalletId]
?.let { ResolvedOwner.OwnWallet(it) }
?: ResolvedOwner.External(address)
}
}
/**
* Flattens every crypto-portfolio account of every wallet into `address -> account` maps keyed by [Network.RawID]
* (a swap's two legs can sit on different networks). Used to decide whether a transfer counterparty is one of the
* user's own accounts/wallets.
*/
internal fun buildOwnAccountAddressMapAllNetworks(
lists: List<AccountStatusList>, lists: List<AccountStatusList>,
networkRawId: Network.RawID, ): Map<Network.RawID, Map<String, Account.CryptoPortfolio>> {
): Map<String, Account.CryptoPortfolio> { val map = mutableMapOf<Network.RawID, MutableMap<String, Account.CryptoPortfolio>>()
val map = mutableMapOf<String, Account.CryptoPortfolio>()
lists.forEach { accountList -> lists.forEach { accountList ->
accountList.accountStatuses accountList.accountStatuses
.filterCryptoPortfolio() .filterCryptoPortfolio()
.forEach { status -> .forEach { status ->
status.flattenCurrencies().forEach { currencyStatus -> status.flattenCurrencies().forEach { currencyStatus ->
if (currencyStatus.currency.network.id.rawId != networkRawId) return@forEach
val address = currencyStatus.value.networkAddress?.defaultAddress?.value ?: return@forEach val address = currencyStatus.value.networkAddress?.defaultAddress?.value ?: return@forEach
map[address] = status.account val rawId = currencyStatus.currency.network.id.rawId
map.getOrPut(rawId) { mutableMapOf() }[address] = status.account
} }
} }
} }
return map return map.mapValues { (_, addresses) -> addresses.toMap() }
} }

View file

@ -2,19 +2,15 @@ package com.tangem.features.txhistory.model
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import arrow.core.Option import arrow.core.Option
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.model.ExpressTx import com.tangem.domain.txhistory.model.ExpressTx
@ -28,7 +24,6 @@ import com.tangem.domain.txhistory.models.TxHistoryStateError
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.features.txhistory.component.TxHistoryComponent import com.tangem.features.txhistory.component.TxHistoryComponent
import com.tangem.features.txhistory.converter.ExpressTxToTransactionItemUMConverter import com.tangem.features.txhistory.converter.ExpressTxToTransactionItemUMConverter
import com.tangem.features.txhistory.converter.TxHistoryInfoToTransactionItemUMConverter import com.tangem.features.txhistory.converter.TxHistoryInfoToTransactionItemUMConverter
@ -60,8 +55,6 @@ internal class TxHistoryModel @Inject constructor(
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val getWalletIconUseCase: GetWalletIconUseCase,
private val walletIconUMConverter: WalletIconUMConverter,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val urlOpener: UrlOpener, private val urlOpener: UrlOpener,
private val txHistoryUpdateListener: TxHistoryUpdateListener, private val txHistoryUpdateListener: TxHistoryUpdateListener,
@ -72,36 +65,13 @@ internal class TxHistoryModel @Inject constructor(
private val appTxHistoryFetcher: AppTxHistoryFetcher, private val appTxHistoryFetcher: AppTxHistoryFetcher,
repository: TxHistoryRepositoryV2, repository: TxHistoryRepositoryV2,
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
multiAccountStatusListSupplier: MultiAccountStatusListSupplier, ownerLookupProducer: TxHistoryOwnerLookupProducer,
isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
userWalletsListRepository: UserWalletsListRepository,
) : Model(), TxHistoryUiActions { ) : Model(), TxHistoryUiActions {
private val params: TxHistoryComponent.Params = paramsContainer.require() private val params: TxHistoryComponent.Params = paramsContainer.require()
private val lookupDataFlow: Flow<TxHistoryLookupContext> = if (designFeatureToggles.isRedesignEnabled) { private val lookupDataFlow: Flow<TxHistoryLookupContext> = if (designFeatureToggles.isRedesignEnabled) {
combine( ownerLookupProducer()
flow = multiAccountStatusListSupplier(),
flow2 = isAccountsModeEnabledUseCase(),
flow3 = userWalletsListRepository.userWallets.filterNotNull(),
transform = ::Triple,
)
.map { (accountLists, modeEnabled, wallets) ->
TxHistoryLookupContext(
ownAccountByAddress = buildOwnAccountAddressMap(
lists = accountLists,
networkRawId = params.currency.network.id.rawId,
),
isAccountsModeEnabled = modeEnabled,
walletInfoById = wallets.associate { wallet ->
wallet.walletId to WalletInfo(
name = wallet.name,
deviceIconUM = walletIconUMConverter.convert(getWalletIconUseCase(wallet)),
)
},
)
}
.distinctUntilChanged()
.flowOn(dispatchers.default) .flowOn(dispatchers.default)
.shareIn(modelScope, SharingStarted.WhileSubscribed(), replay = 1) .shareIn(modelScope, SharingStarted.WhileSubscribed(), replay = 1)
} else { } else {

View file

@ -0,0 +1,57 @@
package com.tangem.features.txhistory.model
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* Single source of the [TxHistoryLookupContext] used by both the history list and the details screen: combines the
* account statuses (own-address map per network), the accounts-mode toggle and the wallet display info. Callers apply
* their own dispatcher / sharing this only produces the cold combined flow.
*/
internal class TxHistoryOwnerLookupProducer @Inject constructor(
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
private val getWalletIconUseCase: GetWalletIconUseCase,
private val walletIconUMConverter: WalletIconUMConverter,
) {
operator fun invoke(): Flow<TxHistoryLookupContext> {
val ownAccountByNetwork = multiAccountStatusListSupplier()
.map(::buildOwnAccountAddressMapAllNetworks)
.distinctUntilChanged()
val walletInfoById = userWalletsListRepository.userWallets
.filterNotNull()
.map { wallets ->
wallets.associate { wallet ->
wallet.walletId to WalletInfo(
name = wallet.name,
deviceIconUM = walletIconUMConverter.convert(getWalletIconUseCase(wallet)),
)
}
}
.distinctUntilChanged()
return combine(
ownAccountByNetwork,
isAccountsModeEnabledUseCase(),
walletInfoById,
) { accounts, modeEnabled, walletInfo ->
TxHistoryLookupContext(
ownAccountByNetwork = accounts,
isAccountsModeEnabled = modeEnabled,
walletInfoById = walletInfo,
)
}
}
}

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.ds.image.TangemDeviceIcon import com.tangem.core.ui.ds.image.TangemDeviceIcon
import com.tangem.core.ui.ds2.row.TangemRow import com.tangem.core.ui.ds2.row.TangemRow
@ -45,9 +46,9 @@ import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.AssetOwnerUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.AssetUM import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.AssetUM
/** /**
* Two-asset ("exchange") block of the details card, used by Swap (and later Onramp): one `bg.tertiary` rounded cell * Two-asset ("exchange") block of the details card, used by Swap and Onramp: one `bg.tertiary` rounded cell
* with the [from] ("You sent") side over the [to] ("You receive") side, split by an inset dashed divider with a * with the [from] side over the [to] side, split by an inset dashed divider with a centered down-arrow masking the
* centered down-arrow masking the line. Each side is a [TangemRow]: label over the signed amount, avatar trailing. * line. Each side is a [TangemRow]: label (with an optional owner) over the signed amount, currency icon trailing.
* *
* [Figma](https://www.figma.com/design/Qqm0dNTOnqtxLYEcmgc32C/Store?node-id=1265-87546) * [Figma](https://www.figma.com/design/Qqm0dNTOnqtxLYEcmgc32C/Store?node-id=1265-87546)
* *
@ -124,9 +125,9 @@ private fun TwoAssetsSideRow(asset: AssetUM, modifier: Modifier = Modifier) {
} }
/** /**
* Caption label above a leg amount. Renders the [label] prefix ("You sent" / "You receive", or "From" / "To" when an * Caption label above a leg amount. Renders the [label] prefix ("You sent" / "You receive" / "You paid", or "From" /
* [owner] is present) and, for a resolved [owner], its inline 16dp decoration in the Figma order the account avatar * "To" when an [owner] is present) and, for a resolved [owner], its inline 16dp decoration in the Figma order the
* leads its name, the wallet key-card icon trails its name. * account avatar and the external-address identicon lead their name, the wallet key-card icon trails its name.
*/ */
@Composable @Composable
private fun TwoAssetsSideLabel(label: TextReference, owner: AssetOwnerUM?, modifier: Modifier = Modifier) { private fun TwoAssetsSideLabel(label: TextReference, owner: AssetOwnerUM?, modifier: Modifier = Modifier) {
@ -137,7 +138,9 @@ private fun TwoAssetsSideLabel(label: TextReference, owner: AssetOwnerUM?, modif
) { ) {
LabelText(text = label) LabelText(text = label)
when (owner) { when (owner) {
is AssetOwnerUM.Account -> { is AssetOwnerUM.Account,
is AssetOwnerUM.Address,
-> {
AssetOwnerIcon(owner = owner) AssetOwnerIcon(owner = owner)
LabelText(text = owner.name, modifier = Modifier.weight(weight = 1f, fill = false)) LabelText(text = owner.name, modifier = Modifier.weight(weight = 1f, fill = false))
} }
@ -162,7 +165,7 @@ private fun LabelText(text: TextReference, modifier: Modifier = Modifier) {
) )
} }
/** 16dp inline owner decoration: the account glyph over its color, or the wallet device card. */ /** 16dp inline owner decoration: the account glyph over its color, the wallet device card, or an address identicon. */
@Composable @Composable
private fun AssetOwnerIcon(owner: AssetOwnerUM, modifier: Modifier = Modifier) { private fun AssetOwnerIcon(owner: AssetOwnerUM, modifier: Modifier = Modifier) {
val iconModifier = modifier.size(16.dp) val iconModifier = modifier.size(16.dp)
@ -186,6 +189,10 @@ private fun AssetOwnerIcon(owner: AssetOwnerUM, modifier: Modifier = Modifier) {
state = owner.deviceIconUM, state = owner.deviceIconUM,
modifier = iconModifier, modifier = iconModifier,
) )
is AssetOwnerUM.Address -> IdentIcon(
address = owner.rawAddress,
modifier = iconModifier.clip(CircleShape),
)
} }
} }
@ -217,7 +224,7 @@ private fun DashedDivider(modifier: Modifier = Modifier) {
// region Preview // region Preview
@Suppress("MagicNumber") @Suppress("MagicNumber", "LongMethod")
@Preview(name = "Light", showBackground = true, widthDp = 360) @Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360) @Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
@Composable @Composable
@ -284,6 +291,27 @@ private fun TxHistoryDetailsTwoAssetsBlockPreview() {
), ),
), ),
) )
// Send-and-swap — the payout went to an external (non-user) address.
TxHistoryDetailsTwoAssetsBlock(
from = previewAsset(
label = "From",
amount = "- 390 USDT",
isFaded = false,
owner = AssetOwnerUM.Wallet(
name = stringReference("Tangem 2.0"),
deviceIconUM = DeviceIconUM.Card(mainColor = Color(0xFF1E1E1E), secondColor = null),
),
),
to = previewAsset(
label = "To",
amount = "+ 1,800.00 POL",
isFaded = false,
owner = AssetOwnerUM.Address(
name = stringReference("33Bd3…a21412B"),
rawAddress = "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359",
),
),
)
} }
} }
} }

View file

@ -53,6 +53,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
// The express payout leg: a real Bitcoin coin so the resolved symbol (BTC) matches the "bitcoin" network id. // The express payout leg: a real Bitcoin coin so the resolved symbol (BTC) matches the "bitcoin" network id.
private val bitcoin = mockCurrencyFactory.bitcoin private val bitcoin = mockCurrencyFactory.bitcoin
private val ownAccount: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 1, name = "Family") private val ownAccount: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 1, name = "Family")
private val secondAccount: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 2, name = "Savings")
private val copiedAddresses = mutableListOf<String>() private val copiedAddresses = mutableListOf<String>()
private val openedUrls = mutableListOf<String>() private val openedUrls = mutableListOf<String>()
private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter( private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter(
@ -834,6 +835,46 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
assertThat(result.to?.label).isEqualTo(resourceReference(R.string.common_to)) assertThat(result.to?.label).isEqualTo(resourceReference(R.string.common_to))
} }
@Test
fun `GIVEN leg with unresolved currency but own address WHEN convert THEN owner resolved cross-network`() {
// Arrange — from leg has no cryptoCurrency (null network), yet its address is owned on exactly one network.
val swap = expressSwap(
status = ExpressExchangeStatus.Finished,
fromAddress = FROM_ADDRESS,
fromCurrency = null,
)
val lookup = lookupOf(currency.network.id.rawId to mapOf(FROM_ADDRESS to ownAccount))
// Act
val result = ownConverter(lookup).convert(swap) as TxHistoryDetailsUM.TwoAssets
// Assert
assertThat(result.from?.owner).isInstanceOf(TxHistoryDetailsUM.AssetOwnerUM.Account::class.java)
assertThat(result.from?.label).isEqualTo(resourceReference(R.string.common_from))
}
@Test
fun `GIVEN leg with unresolved currency and address on two distinct accounts WHEN convert THEN stays external`() {
// Arrange — null network forces a cross-network lookup; the same address maps to two different accounts.
val swap = expressSwap(
status = ExpressExchangeStatus.Finished,
fromAddress = FROM_ADDRESS,
fromCurrency = null,
)
val lookup = lookupOf(
currency.network.id.rawId to mapOf(FROM_ADDRESS to ownAccount),
bitcoin.network.id.rawId to mapOf(FROM_ADDRESS to secondAccount),
)
// Act
val result = ownConverter(lookup).convert(swap) as TxHistoryDetailsUM.TwoAssets
// Assert — ambiguous, so it falls back to the external address rather than guessing an owner.
val owner = result.from?.owner
assertThat(owner).isInstanceOf(TxHistoryDetailsUM.AssetOwnerUM.Address::class.java)
assertThat((owner as TxHistoryDetailsUM.AssetOwnerUM.Address).rawAddress).isEqualTo(FROM_ADDRESS)
}
// endregion // endregion
private fun onChain( private fun onChain(