Updated on 2026-08-14
This commit is contained in:
parent
c061cc88f1
commit
ed2c3c3078
9 changed files with 153 additions and 29 deletions
|
|
@ -14,18 +14,22 @@ internal class Eip681PaymentUriParser(
|
|||
coins: List<CryptoCurrency.Coin>,
|
||||
allCurrencies: List<CryptoCurrency>,
|
||||
): PaymentUriParser.ParseResult {
|
||||
if (!qrCode.startsWith(SCHEME)) return PaymentUriParser.ParseResult.NotRecognized
|
||||
if (!qrCode.startsWith(SCHEME)) {
|
||||
return PaymentUriParser.ParseResult.NotRecognized
|
||||
}
|
||||
|
||||
val withoutScheme = qrCode.removePrefix(SCHEME)
|
||||
val parsed = parseEip681(withoutScheme) ?: return PaymentUriParser.ParseResult.NotRecognized
|
||||
|
||||
val matchingCoins = findMatchingCoins(parsed.chainId, coins)
|
||||
if (matchingCoins.isEmpty()) return PaymentUriParser.ParseResult.RecognizedButNoMatch
|
||||
if (matchingCoins.isEmpty()) {
|
||||
return PaymentUriParser.ParseResult.RecognizedButNoMatch
|
||||
}
|
||||
|
||||
val result = if (parsed.functionName == FUNCTION_TRANSFER) {
|
||||
resolveErc20Transfer(parsed, matchingCoins, allCurrencies)
|
||||
} else {
|
||||
resolveNativeTransfer(parsed, matchingCoins)
|
||||
resolveNativeTransfer(parsed, matchingCoins, allCurrencies)
|
||||
}
|
||||
return if (result != null) {
|
||||
PaymentUriParser.ParseResult.Success(result)
|
||||
|
|
@ -37,6 +41,7 @@ internal class Eip681PaymentUriParser(
|
|||
private fun resolveNativeTransfer(
|
||||
parsed: Eip681Result,
|
||||
matchingCoins: List<CryptoCurrency.Coin>,
|
||||
allCurrencies: List<CryptoCurrency>,
|
||||
): ClassifiedQrContent.PaymentUri? {
|
||||
val valueWei = parsed.params[PARAM_VALUE]?.toBigDecimalOrNull()
|
||||
|
||||
|
|
@ -45,11 +50,20 @@ internal class Eip681PaymentUriParser(
|
|||
val decimals = matchingCoins.first().decimals
|
||||
val amount = valueWei?.fromSmallestUnit(decimals)
|
||||
|
||||
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
|
||||
// If value is specified, this is a native coin transfer — return only coins
|
||||
// If no value, it's just an address with scheme — return all currencies on the network
|
||||
val matchingCurrencies = if (valueWei != null) {
|
||||
matchingCoins
|
||||
} else {
|
||||
allCurrencies.filter { it.network.id in matchingNetworkIds }
|
||||
}
|
||||
|
||||
return ClassifiedQrContent.PaymentUri(
|
||||
address = parsed.targetAddress,
|
||||
amount = amount,
|
||||
memo = null,
|
||||
matchingCurrencies = matchingCoins,
|
||||
matchingCurrencies = matchingCurrencies,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -63,22 +77,22 @@ internal class Eip681PaymentUriParser(
|
|||
|
||||
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
|
||||
|
||||
val tokens = allCurrencies.filterIsInstance<CryptoCurrency.Token>()
|
||||
|
||||
val token = tokens
|
||||
.firstOrNull { token ->
|
||||
val matchingTokens = allCurrencies.filterIsInstance<CryptoCurrency.Token>()
|
||||
.filter { token ->
|
||||
token.network.id in matchingNetworkIds &&
|
||||
token.contractAddress.equals(contractAddress, ignoreCase = true)
|
||||
} ?: return null
|
||||
}
|
||||
|
||||
if (matchingTokens.isEmpty()) return null
|
||||
|
||||
val rawAmount = parsed.params[PARAM_UINT256]?.toBigDecimalOrNull()
|
||||
val amount = rawAmount?.fromSmallestUnit(token.decimals)
|
||||
val amount = rawAmount?.fromSmallestUnit(matchingTokens.first().decimals)
|
||||
|
||||
return ClassifiedQrContent.PaymentUri(
|
||||
address = recipient,
|
||||
amount = amount,
|
||||
memo = null,
|
||||
matchingCurrencies = listOf(token),
|
||||
matchingCurrencies = matchingTokens,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,10 +29,9 @@ internal class QrContentClassifierParser(
|
|||
is PaymentUriParser.ParseResult.NotRecognized -> Unit
|
||||
}
|
||||
|
||||
val matchingNetworkIds = uniqueCoins
|
||||
val matchingCoins = uniqueCoins
|
||||
.filter { coin -> blockchainDataProvider.validateAddress(coin.network, qrCode) }
|
||||
.map { it.network.id }
|
||||
.toSet()
|
||||
val matchingNetworkIds = matchingCoins.map { it.network.id }.toSet()
|
||||
|
||||
if (matchingNetworkIds.isNotEmpty()) {
|
||||
val matchingCurrencies = userCurrencies.filter { it.network.id in matchingNetworkIds }
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ internal class Eip681PaymentUriParserTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `native transfer includes only coins, not tokens`() {
|
||||
fun `native transfer with value returns only coins`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
|
||||
|
|
@ -85,6 +85,22 @@ internal class Eip681PaymentUriParserTest {
|
|||
assertThat(result!!.matchingCurrencies).containsExactly(ethereumCoin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native transfer without value returns all currencies on matching network`() {
|
||||
every { blockchainDataProvider.getChainId(ethereumCoin.network) } returns 1L
|
||||
|
||||
val usdcToken = buildToken("ethereum", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
|
||||
|
||||
val result = parser.parse(
|
||||
qrCode = "ethereum:0xRecipient@1",
|
||||
coins = listOf(ethereumCoin),
|
||||
allCurrencies = listOf(ethereumCoin, usdcToken),
|
||||
).asSuccess()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.matchingCurrencies).containsExactly(ethereumCoin, usdcToken)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region ERC-20 transfer
|
||||
|
|
|
|||
|
|
@ -27,19 +27,19 @@ class ResolveQrSendTargetsUseCase(
|
|||
val currencyLocations = mutableMapOf<CryptoCurrency.ID, MutableList<CurrencyLocation>>()
|
||||
val totalPerAccount = mutableMapOf<AccountId, Int>()
|
||||
|
||||
for (accountList in allAccountLists) {
|
||||
for (account in accountList.accounts.filterIsInstance<Account.CryptoPortfolio>()) {
|
||||
val location = CurrencyLocation(
|
||||
walletName = walletNamesMap[account.accountId.userWalletId]
|
||||
?: account.accountId.userWalletId.stringValue,
|
||||
accountId = account.accountId,
|
||||
accountName = account.accountName,
|
||||
)
|
||||
totalPerAccount[account.accountId] = account.cryptoCurrencies.size
|
||||
for (currency in account.cryptoCurrencies) {
|
||||
allCurrencies.add(currency)
|
||||
currencyLocations.getOrPut(currency.id) { mutableListOf() }.add(location)
|
||||
}
|
||||
val allAccounts = allAccountLists.flatMap { it.accounts }.filterIsInstance<Account.CryptoPortfolio>()
|
||||
|
||||
for (account in allAccounts) {
|
||||
val location = CurrencyLocation(
|
||||
walletName = walletNamesMap[account.accountId.userWalletId]
|
||||
?: account.accountId.userWalletId.stringValue,
|
||||
accountId = account.accountId,
|
||||
accountName = account.accountName,
|
||||
)
|
||||
totalPerAccount[account.accountId] = account.cryptoCurrencies.size
|
||||
for (currency in account.cryptoCurrencies) {
|
||||
allCurrencies.add(currency)
|
||||
currencyLocations.getOrPut(currency.id) { mutableListOf() }.add(location)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,10 +30,12 @@ import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen
|
|||
import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen2
|
||||
import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedComponent
|
||||
import com.tangem.feature.walletsettings.component.RenameWalletComponent
|
||||
import com.tangem.core.ui.utils.parseBigDecimal
|
||||
import com.tangem.features.biometry.AskBiometryComponent
|
||||
import com.tangem.features.feed.entry.components.FeedEntryComponent
|
||||
import com.tangem.features.pushnotifications.api.PushNotificationsBottomSheetComponent
|
||||
import com.tangem.features.pushnotifications.api.PushNotificationsParams
|
||||
import com.tangem.features.send.v2.api.NetworkSelectionComponent
|
||||
import com.tangem.features.tokenreceive.TokenReceiveComponent
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent
|
||||
import dagger.assisted.Assisted
|
||||
|
|
@ -52,6 +54,7 @@ internal class WalletComponent @AssistedInject constructor(
|
|||
private val pushNotificationsBottomSheetComponent: PushNotificationsBottomSheetComponent.Factory,
|
||||
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
|
||||
private val yieldSupplyDepositedWarningComponent: YieldSupplyDepositedWarningComponent.Factory,
|
||||
private val networkSelectionComponentFactory: NetworkSelectionComponent.Factory,
|
||||
private val designFeatureToggles: DesignFeatureToggles,
|
||||
) : ComposableContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
|
|
@ -156,6 +159,41 @@ internal class WalletComponent @AssistedInject constructor(
|
|||
),
|
||||
)
|
||||
}
|
||||
is WalletDialogConfig.NetworkSelection -> {
|
||||
networkSelectionComponentFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = NetworkSelectionComponent.Params(
|
||||
address = dialogConfig.address,
|
||||
amount = dialogConfig.amount,
|
||||
memo = dialogConfig.memo,
|
||||
walletGroups = dialogConfig.walletGroups.map { walletGroup ->
|
||||
NetworkSelectionComponent.Params.WalletGroup(
|
||||
userWalletId = walletGroup.userWalletId,
|
||||
walletName = walletGroup.walletName,
|
||||
accounts = walletGroup.accounts.map { accountGroup ->
|
||||
NetworkSelectionComponent.Params.AccountGroup(
|
||||
accountId = accountGroup.accountId,
|
||||
accountName = accountGroup.accountName,
|
||||
currencies = accountGroup.currencies,
|
||||
hiddenTokensCount = accountGroup.hiddenTokensCount,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
onTokenSelected = { userWalletId, currency ->
|
||||
model.innerWalletRouter.dialogNavigation.dismiss()
|
||||
model.innerWalletRouter.openSend(
|
||||
userWalletId = userWalletId,
|
||||
currency = currency,
|
||||
address = dialogConfig.address,
|
||||
amount = dialogConfig.amount?.parseBigDecimal(currency.decimals),
|
||||
tag = dialogConfig.memo,
|
||||
)
|
||||
},
|
||||
onDismiss = model.innerWalletRouter.dialogNavigation::dismiss,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -776,7 +776,7 @@ internal class WalletModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
is QrSendTarget.Multiple -> {
|
||||
// TODO: [REDACTED_TASK_KEY] Bottom sheet: Wallets (dropdown) → Accounts → Tokens
|
||||
innerWalletRouter.openNetworkSelectionBottomSheet(target)
|
||||
}
|
||||
is QrSendTarget.Unknown -> {
|
||||
// TODO: [REDACTED_TASK_KEY] Error handling for unsupported and invalid QR codes
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.tangem.domain.models.account.AccountId
|
|||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.qrscanning.models.QrSendTarget
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.TangemPayDetailsConfig
|
||||
|
|
@ -197,6 +198,30 @@ internal class DefaultWalletRouter @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
override fun openNetworkSelectionBottomSheet(target: QrSendTarget.Multiple) {
|
||||
dialogNavigation.activate(
|
||||
configuration = WalletDialogConfig.NetworkSelection(
|
||||
address = target.address,
|
||||
amount = target.amount,
|
||||
memo = target.memo,
|
||||
walletGroups = target.walletGroups.map { walletGroup ->
|
||||
WalletDialogConfig.NetworkSelection.WalletGroupData(
|
||||
userWalletId = walletGroup.userWalletId,
|
||||
walletName = walletGroup.walletName,
|
||||
accounts = walletGroup.accounts.map { accountGroup ->
|
||||
WalletDialogConfig.NetworkSelection.AccountGroupData(
|
||||
accountId = accountGroup.accountId,
|
||||
accountName = accountGroup.accountName,
|
||||
currencies = accountGroup.currencies,
|
||||
hiddenTokensCount = accountGroup.hiddenTokensCount,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
inner class OrganizeCallbacks : OrganizeTokensComponent.Callback {
|
||||
override fun onDismiss() {
|
||||
dialogNavigation.dismiss()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.tangem.domain.models.account.AccountId
|
|||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.qrscanning.models.QrSendTarget
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.TangemPayDetailsConfig
|
||||
|
|
@ -95,4 +96,7 @@ internal interface InnerWalletRouter {
|
|||
|
||||
/** Open send screen with prefilled destination */
|
||||
fun openSend(userWalletId: UserWalletId, currency: CryptoCurrency, address: String, amount: String?, tag: String?)
|
||||
|
||||
/** Open network selection bottom sheet for multiple QR matches */
|
||||
fun openNetworkSelectionBottomSheet(target: QrSendTarget.Multiple)
|
||||
}
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
package com.tangem.feature.wallet.presentation.wallet.state.model
|
||||
|
||||
import com.tangem.domain.models.TokenReceiveConfig
|
||||
import com.tangem.domain.models.account.AccountId
|
||||
import com.tangem.domain.models.account.AccountName
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.serialization.BigDecimalSerializer
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.tokens.model.details.TokenAction
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Wallet dialog config. Used to show Decompose dialogs
|
||||
|
|
@ -44,4 +48,28 @@ internal sealed interface WalletDialogConfig {
|
|||
|
||||
@Serializable
|
||||
data class OrganizeTokens(val userWalletId: UserWalletId) : WalletDialogConfig
|
||||
|
||||
@Serializable
|
||||
data class NetworkSelection(
|
||||
val address: String,
|
||||
val amount: @Serializable(BigDecimalSerializer::class) BigDecimal?,
|
||||
val memo: String?,
|
||||
val walletGroups: List<WalletGroupData>,
|
||||
) : WalletDialogConfig {
|
||||
|
||||
@Serializable
|
||||
data class WalletGroupData(
|
||||
val userWalletId: UserWalletId,
|
||||
val walletName: String,
|
||||
val accounts: List<AccountGroupData>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AccountGroupData(
|
||||
val accountId: AccountId,
|
||||
val accountName: AccountName,
|
||||
val currencies: List<CryptoCurrency>,
|
||||
val hiddenTokensCount: Int = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue