Updated on 2026-08-14

This commit is contained in:
Tangem 2026-08-13 17:52:05 +04:00
parent cd266b50f5
commit 00126b9d36
21 changed files with 711 additions and 16 deletions

View file

@ -45,6 +45,8 @@ import kotlinx.coroutines.delay
* @param placeholder The placeholder text reference to show when the input is empty * @param placeholder The placeholder text reference to show when the input is empty
* @param onValueChange Callback invoked when the input value changes, receives the new string value * @param onValueChange Callback invoked when the input value changes, receives the new string value
* @param onPasteClick Callback invoked when the paste button is clicked, receives the pasted string * @param onPasteClick Callback invoked when the paste button is clicked, receives the pasted string
* @param onClearClick Callback invoked when the cross icon is clicked to clear the field. Separate from
* [onPasteClick] on purpose: clearing used to be delivered as a paste of an empty string
* @param onQrCodeClick Callback invoked when the QR code scan button is clicked * @param onQrCodeClick Callback invoked when the QR code scan button is clicked
* @param modifier Optional modifier to apply to the composable * @param modifier Optional modifier to apply to the composable
* @param singleLine Whether the text field should be constrained to a single line (default: false) * @param singleLine Whether the text field should be constrained to a single line (default: false)
@ -64,6 +66,7 @@ fun InputRowRecipient(
placeholder: TextReference, placeholder: TextReference,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
onPasteClick: (String) -> Unit, onPasteClick: (String) -> Unit,
onClearClick: () -> Unit,
onQrCodeClick: () -> Unit, onQrCodeClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
singleLine: Boolean = false, singleLine: Boolean = false,
@ -121,7 +124,7 @@ fun InputRowRecipient(
.testTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD), .testTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD),
) )
CrossIcon( CrossIcon(
onClick = onPasteClick, onClick = onClearClick,
modifier = Modifier modifier = Modifier
.align(CenterVertically) .align(CenterVertically)
.padding(start = TangemTheme.dimens.spacing8) .padding(start = TangemTheme.dimens.spacing8)
@ -258,6 +261,7 @@ private fun InputRowRecipientPreview(
showDivider = true, showDivider = true,
onValueChange = {}, onValueChange = {},
onPasteClick = {}, onPasteClick = {},
onClearClick = {},
onQrCodeClick = {}, onQrCodeClick = {},
modifier = Modifier.background(TangemTheme.colors.background.primary), modifier = Modifier.background(TangemTheme.colors.background.primary),
resolvedAddress = value.resolvedAddress, resolvedAddress = value.resolvedAddress,

View file

@ -90,7 +90,7 @@ fun PasteButton(
} }
@Composable @Composable
fun CrossIcon(onClick: (String) -> Unit, modifier: Modifier = Modifier) { fun CrossIcon(onClick: () -> Unit, modifier: Modifier = Modifier) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_close_24), painter = painterResource(id = R.drawable.ic_close_24),
tint = TangemTheme.colors.icon.informative, tint = TangemTheme.colors.icon.informative,
@ -100,7 +100,7 @@ fun CrossIcon(onClick: (String) -> Unit, modifier: Modifier = Modifier) {
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = ripple(radius = TangemTheme.dimens.radius12), indication = ripple(radius = TangemTheme.dimens.radius12),
onClick = { onClick("") }, onClick = onClick,
), ),
) )
} }

View file

@ -0,0 +1,24 @@
package com.tangem.domain.models.network
/** Addresses nobody holds the private key for: anything sent there is destroyed with no way to recover it. */
private val BURN_ADDRESS_BODIES = setOf(
// 0x0000000000000000000000000000000000000000
"0000000000000000000000000000000000000000",
// 0x000000000000000000000000000000000000dEaD
"000000000000000000000000000000000000dead",
)
private const val HEX_PREFIX = "0x"
/**
* Whether this address is a burn address, see [BURN_ADDRESS_BODIES]. Such an address is well-formed, so nothing
* else in the validation chain rejects it it has to be blacklisted explicitly.
*
* Case- and prefix-insensitive: checksummed addresses mix the case, and a recipient decoded back from call data
* comes without the `0x` prefix.
*/
fun String.isBurnAddress(): Boolean {
val body = trim().removePrefix(HEX_PREFIX).removePrefix(HEX_PREFIX.uppercase()).lowercase()
return body in BURN_ADDRESS_BODIES
}

View file

@ -0,0 +1,42 @@
package com.tangem.domain.models.network
import com.google.common.truth.Truth.assertThat
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class BurnAddressTest {
@ParameterizedTest
@ProvideTestModels
fun isBurnAddress(model: TestModel) {
// Act
val actual = model.address.isBurnAddress()
// Assert
assertThat(actual).isEqualTo(model.expected)
}
internal data class TestModel(val address: String, val expected: Boolean)
private fun provideTestModels() = listOf(
TestModel(address = "0x0000000000000000000000000000000000000000", expected = true),
TestModel(address = "0x000000000000000000000000000000000000dEaD", expected = true),
// The dead address is usually written EIP-55 checksummed, but nothing forces it
TestModel(address = "0x000000000000000000000000000000000000dead", expected = true),
TestModel(address = "0x000000000000000000000000000000000000DEAD", expected = true),
// A recipient decoded back from call data comes without the prefix
TestModel(address = "0000000000000000000000000000000000000000", expected = true),
TestModel(address = "000000000000000000000000000000000000dEaD", expected = true),
TestModel(address = " 0x000000000000000000000000000000000000dEaD ", expected = true),
// A regular recipient
TestModel(address = "0xfc9013965447f804042a03ae4b98130a8c300a2f", expected = false),
// Only the exact addresses are blacklisted, not everything that merely looks dead
TestModel(address = "0x000000000000000000000000000000000000dEaE", expected = false),
TestModel(address = "0xdEaD000000000000000042069420694206942069", expected = false),
// Not an EVM address at all
TestModel(address = "TWd4WrZ9wn84f5x1hZhL4DHvk738ns5jwb", expected = false),
TestModel(address = "", expected = false),
)
}

View file

@ -28,4 +28,8 @@ dependencies {
api(projects.domain.models) api(projects.domain.models)
api(projects.domain.qrScanning.models) api(projects.domain.qrScanning.models)
// endregion // endregion
// region Test
testImplementation(projects.test.core)
// endregion
} }

View file

@ -6,6 +6,7 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.account.AccountName
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.Network
import com.tangem.domain.models.network.isBurnAddress
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.networks.repository.NetworksRepository import com.tangem.domain.networks.repository.NetworksRepository
@ -52,14 +53,19 @@ class ResolveQrSendTargetsUseCase(
val classified = qrScanningEventsRepository.classify(qrCode, allCurrencies) val classified = qrScanningEventsRepository.classify(qrCode, allCurrencies)
val portfolioIndex = PortfolioIndex(currencyLocations, totalPerAccount) val portfolioIndex = PortfolioIndex(currencyLocations, totalPerAccount)
return resolve(classified, portfolioIndex) return resolve(qrCode, classified, portfolioIndex)
} }
private suspend fun resolve(classified: ClassifiedQrContent, portfolioIndex: PortfolioIndex): QrSendTarget { private suspend fun resolve(
qrCode: String,
classified: ClassifiedQrContent,
portfolioIndex: PortfolioIndex,
): QrSendTarget {
return when (classified) { return when (classified) {
is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri) is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri)
is ClassifiedQrContent.Error -> QrSendTarget.Error(classified) is ClassifiedQrContent.Error -> QrSendTarget.Error(classified)
is ClassifiedQrContent.PlainAddress -> resolveAddressTarget( is ClassifiedQrContent.PlainAddress -> resolveAddressTarget(
qrCode = qrCode,
address = classified.address, address = classified.address,
amount = null, amount = null,
memo = null, memo = null,
@ -67,6 +73,7 @@ class ResolveQrSendTargetsUseCase(
portfolioIndex = portfolioIndex, portfolioIndex = portfolioIndex,
) )
is ClassifiedQrContent.PaymentUri -> resolveAddressTarget( is ClassifiedQrContent.PaymentUri -> resolveAddressTarget(
qrCode = qrCode,
address = classified.address, address = classified.address,
amount = classified.amount, amount = classified.amount,
memo = classified.memo, memo = classified.memo,
@ -74,22 +81,32 @@ class ResolveQrSendTargetsUseCase(
portfolioIndex = portfolioIndex, portfolioIndex = portfolioIndex,
) )
is ClassifiedQrContent.PaymentUriWarning -> { is ClassifiedQrContent.PaymentUriWarning -> {
val inner = resolve(classified.paymentUri, portfolioIndex) when (val inner = resolve(qrCode, classified.paymentUri, portfolioIndex)) {
QrSendTarget.Warning( // Asking whether to continue with unsupported parameters makes no sense for a QR that is
// rejected anyway — the error would only show up after the user confirms the warning.
is QrSendTarget.Error -> inner
else -> QrSendTarget.Warning(
target = inner, target = inner,
unsupportedParams = classified.unsupportedParams, unsupportedParams = classified.unsupportedParams,
) )
} }
} }
} }
}
@Suppress("LongParameterList")
private suspend fun resolveAddressTarget( private suspend fun resolveAddressTarget(
qrCode: String,
address: String, address: String,
amount: BigDecimal?, amount: BigDecimal?,
memo: String?, memo: String?,
matchingCurrencies: List<CryptoCurrency>, matchingCurrencies: List<CryptoCurrency>,
portfolioIndex: PortfolioIndex, portfolioIndex: PortfolioIndex,
): QrSendTarget { ): QrSendTarget {
if (address.isBurnAddress()) {
return QrSendTarget.Error(ClassifiedQrContent.Error.Unrecognized(raw = qrCode))
}
val ownAddressNetworks = findOwnAddressNetworks(address, matchingCurrencies, portfolioIndex) val ownAddressNetworks = findOwnAddressNetworks(address, matchingCurrencies, portfolioIndex)
val walletGroups = buildWalletGroups(matchingCurrencies, portfolioIndex, ownAddressNetworks) val walletGroups = buildWalletGroups(matchingCurrencies, portfolioIndex, ownAddressNetworks)

View file

@ -0,0 +1,119 @@
package com.tangem.domain.qrscanning.usecases
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.networks.repository.NetworksRepository
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import com.tangem.domain.qrscanning.models.QrSendTarget
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import com.tangem.test.core.ProvideTestModels
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ResolveQrSendTargetsUseCaseTest {
private val multiAccountListSupplier: MultiAccountListSupplier = mockk(relaxed = true)
private val qrScanningEventsRepository: QrScanningEventsRepository = mockk(relaxed = true)
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true)
private val networksRepository: NetworksRepository = mockk(relaxed = true)
private val currency: CryptoCurrency = mockk(relaxed = true)
private val useCase = ResolveQrSendTargetsUseCase(
multiAccountListSupplier = multiAccountListSupplier,
qrScanningEventsRepository = qrScanningEventsRepository,
userWalletsListRepository = userWalletsListRepository,
networksRepository = networksRepository,
)
@ParameterizedTest
@ProvideTestModels
fun burnAddress(model: BurnAddressModel) = runTest {
// Arrange — a burn address is well-formed, so the classifier itself accepts it
coEvery { multiAccountListSupplier.getSyncOrNull(Unit) } returns null
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
every { qrScanningEventsRepository.classify(model.qrCode, any()) } returns model.classified
// Act
val actual = useCase(model.qrCode)
// Assert — the send screen must not open at all for a recipient nobody can spend from
assertThat(actual).isInstanceOf(QrSendTarget.Error::class.java)
val error = (actual as QrSendTarget.Error).error
assertThat(error).isInstanceOf(ClassifiedQrContent.Error.Unrecognized::class.java)
assertThat((error as ClassifiedQrContent.Error.Unrecognized).raw).isEqualTo(model.qrCode)
}
internal data class BurnAddressModel(val qrCode: String, val classified: ClassifiedQrContent)
private fun provideTestModels() = listOf(
BurnAddressModel(
qrCode = ZERO_ADDRESS,
classified = ClassifiedQrContent.PlainAddress(
address = ZERO_ADDRESS,
matchingCurrencies = listOf(currency),
),
),
BurnAddressModel(
qrCode = DEAD_ADDRESS,
classified = ClassifiedQrContent.PlainAddress(
address = DEAD_ADDRESS,
matchingCurrencies = listOf(currency),
),
),
// ERC-681 with the burn address as the transfer() recipient — the shape that skips the recipient screen
BurnAddressModel(
qrCode = "ethereum:$USDT_CONTRACT@1/transfer?address=$DEAD_ADDRESS&uint256=1000000",
classified = ClassifiedQrContent.PaymentUri(
address = DEAD_ADDRESS,
amount = null,
memo = null,
matchingCurrencies = listOf(currency),
),
),
// Warning wrapper must not smuggle a burn recipient through either
BurnAddressModel(
qrCode = "ethereum:$DEAD_ADDRESS@1?value=1000&unknown=1",
classified = ClassifiedQrContent.PaymentUriWarning(
paymentUri = ClassifiedQrContent.PaymentUri(
address = DEAD_ADDRESS,
amount = null,
memo = null,
matchingCurrencies = listOf(currency),
),
unsupportedParams = mapOf("unknown" to "1"),
),
),
)
@Test
fun `GIVEN regular address WHEN resolve THEN not rejected as unrecognized`() = runTest {
// Arrange
coEvery { multiAccountListSupplier.getSyncOrNull(Unit) } returns null
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
every { qrScanningEventsRepository.classify(RECIPIENT, any()) } returns ClassifiedQrContent.PlainAddress(
address = RECIPIENT,
matchingCurrencies = listOf(currency),
)
// Act
val actual = useCase(RECIPIENT)
// Assert — with an empty portfolio it resolves to AddressSameAsWallet, but never to an error
assertThat(actual).isNotInstanceOf(QrSendTarget.Error::class.java)
}
private companion object {
const val ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
const val DEAD_ADDRESS = "0x000000000000000000000000000000000000dEaD"
const val RECIPIENT = "0xfc9013965447f804042a03ae4b98130a8c300a2f"
const val USDT_CONTRACT = "0xdac17f958d2ee523a2206206994597c13d831ec7"
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.nft.models.NFTAsset import com.tangem.blockchain.nft.models.NFTAsset
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.models.network.isBurnAddress
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
class CreateNFTTransferTransactionUseCase( class CreateNFTTransferTransactionUseCase(
@ -23,6 +24,8 @@ class CreateNFTTransferTransactionUseCase(
userWalletId: UserWalletId, userWalletId: UserWalletId,
network: Network, network: Network,
) = Either.catch { ) = Either.catch {
requireSpendableDestination(destinationAddress)
transactionRepository.createNFTTransferTransaction( transactionRepository.createNFTTransferTransaction(
ownerAddress = ownerAddress, ownerAddress = ownerAddress,
nftAsset = nftAsset, nftAsset = nftAsset,
@ -46,6 +49,8 @@ class CreateNFTTransferTransactionUseCase(
userWalletId: UserWalletId, userWalletId: UserWalletId,
network: Network, network: Network,
) = Either.catch { ) = Either.catch {
requireSpendableDestination(destinationAddress)
transactionRepository.createNFTTransferTransaction( transactionRepository.createNFTTransferTransaction(
ownerAddress = ownerAddress, ownerAddress = ownerAddress,
nftAsset = nftAsset, nftAsset = nftAsset,
@ -56,4 +61,10 @@ class CreateNFTTransferTransactionUseCase(
network = network, network = network,
) )
} }
/** Same barrier as on the coin/token transfer path, see [CreateTransferTransactionUseCase]. */
private fun requireSpendableDestination(destinationAddress: String) {
require(destinationAddress.isNotBlank()) { "Transfers with a blank destination are not allowed" }
require(!destinationAddress.isBurnAddress()) { "Transfers to a burn address are not allowed" }
}
} }

View file

@ -5,6 +5,7 @@ import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.models.network.isBurnAddress
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigInteger import java.math.BigInteger
@ -31,6 +32,8 @@ class CreateTransferTransactionUseCase(
network: Network, network: Network,
nonce: BigInteger? = null, nonce: BigInteger? = null,
) = Either.catch { ) = Either.catch {
requireSpendableDestination(destination)
transactionRepository.createTransferTransaction( transactionRepository.createTransferTransaction(
amount = amount, amount = amount,
fee = fee, fee = fee,
@ -54,6 +57,8 @@ class CreateTransferTransactionUseCase(
network: Network, network: Network,
nonce: BigInteger? = null, nonce: BigInteger? = null,
) = Either.catch { ) = Either.catch {
requireSpendableDestination(destination)
transactionRepository.createTransferTransaction( transactionRepository.createTransferTransaction(
amount = amount, amount = amount,
memo = memo, memo = memo,
@ -64,4 +69,17 @@ class CreateTransferTransactionUseCase(
network = network, network = network,
) )
} }
/**
* The recipient typed on the send screen is already rejected by [ValidateWalletAddressUseCase], but a send
* deeplink jumps straight to the confirmation step, bypassing that screen hence the check here, at the single
* point every transfer is built at.
*
* A blank recipient is rejected together with the burn ones: it degrades into the zero address once the call
* data is encoded.
*/
private fun requireSpendableDestination(destination: String) {
require(destination.isNotBlank()) { "Transfers with a blank destination are not allowed" }
require(!destination.isBurnAddress()) { "Transfers to a burn address are not allowed" }
}
} }

View file

@ -9,6 +9,7 @@ import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.transaction.WalletAddressServiceRepository import com.tangem.domain.transaction.WalletAddressServiceRepository
import com.tangem.domain.transaction.error.AddressValidation import com.tangem.domain.transaction.error.AddressValidation
import com.tangem.domain.transaction.error.AddressValidationResult import com.tangem.domain.transaction.error.AddressValidationResult
import com.tangem.domain.models.network.isBurnAddress
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.lib.crypto.BlockchainUtils import com.tangem.lib.crypto.BlockchainUtils
@ -60,6 +61,9 @@ class ValidateWalletAddressUseCase(
allowSelfSend: Boolean, allowSelfSend: Boolean,
isCurrentAddress: (String) -> Boolean, isCurrentAddress: (String) -> Boolean,
): AddressValidationResult { ): AddressValidationResult {
// A burn address is well-formed, so every check below would accept it.
if (address.isBurnAddress()) return AddressValidation.Error.InvalidAddress.left()
val decodedXAddress = BlockchainUtils.decodeRippleXAddress(address, network.rawId) val decodedXAddress = BlockchainUtils.decodeRippleXAddress(address, network.rawId)
val isSelfSendAvailable = walletManagersFacade.checkSelfSendAvailability(userWalletId, network) val isSelfSendAvailable = walletManagersFacade.checkSelfSendAvailability(userWalletId, network)
@ -76,7 +80,10 @@ class ValidateWalletAddressUseCase(
address = addressToValidate, address = addressToValidate,
) )
if (resolveAddressResult is ResolveAddressResult.Resolved) { // A name may resolve to a burn address too, so the resolved one goes through the same blacklist.
if (resolveAddressResult is ResolveAddressResult.Resolved &&
!resolveAddressResult.address.isBurnAddress()
) {
AddressValidation.Success.ValidNamedAddress(resolveAddressResult.address).right() AddressValidation.Success.ValidNamedAddress(resolveAddressResult.address).right()
} else { } else {
AddressValidation.Error.InvalidAddress.left() AddressValidation.Error.InvalidAddress.left()

View file

@ -9,6 +9,7 @@ import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
import com.tangem.blockchain.blockchains.ethereum.gasless.EthereumGaslessDataProvider import com.tangem.blockchain.blockchains.ethereum.gasless.EthereumGaslessDataProvider
import com.tangem.blockchain.blockchains.ethereum.models.EIP7702AuthorizationData import com.tangem.blockchain.blockchains.ethereum.models.EIP7702AuthorizationData
import com.tangem.blockchain.common.* import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.extensions.Result
import com.tangem.blockchain.extensions.formatHex import com.tangem.blockchain.extensions.formatHex
@ -31,6 +32,7 @@ import com.tangem.domain.transaction.models.GaslessBatchTransactionData
import com.tangem.domain.transaction.models.GaslessFeePlan import com.tangem.domain.transaction.models.GaslessFeePlan
import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.domain.transaction.models.GaslessTransactionData
import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.models.network.isBurnAddress
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import java.math.BigInteger import java.math.BigInteger
@ -98,6 +100,11 @@ class CreateAndSendGaslessTransactionUseCase(
"does not support gasless transactions", "does not support gasless transactions",
) )
validateTransactionRecipients(
blockchain = walletManager.wallet.blockchain,
transactionData = transactionData,
)
val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress) val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress)
val mainTxGasLimit = fee.mainTransactionGasLimit val mainTxGasLimit = fee.mainTransactionGasLimit
@ -462,5 +469,38 @@ class CreateAndSendGaslessTransactionUseCase(
txData.contractAddress ?: error("supports only Token transaction with contract address") txData.contractAddress ?: error("supports only Token transaction with contract address")
} }
} }
/**
* Last line of defence before the meta-transaction is signed: the gasless path never reaches
* `TransactionSender.send`, so the `TransactionValidator` guarding the regular EVM send never runs for it.
* The user signs the payload himself, so neither the gasless service nor the chain can reject it afterwards.
*/
internal fun validateTransactionRecipients(
blockchain: Blockchain,
transactionData: TransactionData.Uncompiled,
) {
val destination = transactionData.destinationAddress
require(blockchain.validateAddress(destination) && !destination.isBurnAddress()) {
"Invalid destination address for a gasless transaction"
}
val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData
?: error("Ethereum call data is required")
require(callData.validate(blockchain)) {
"Invalid call data for a gasless transaction"
}
require(callData.recipientOrNull()?.isBurnAddress() != true) {
"Burn address recipient in the call data of a gasless transaction"
}
}
/**
* The recipient encoded inside the call data, when it differs from the transaction's own destination.
* Only the yield-supply send needs it: its `to` is the user's yield module.
*/
private fun SmartContractCallData.recipientOrNull(): String? = when (this) {
is EthereumYieldSupplySendCallData -> destinationAddress
else -> null
}
} }
} }

View file

@ -0,0 +1,104 @@
package com.tangem.domain.transaction.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.test.core.ProvideTestModels
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.clearMocks
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
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
/** Unit tests for the destination barrier of [CreateTransferTransactionUseCase]. */
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class CreateTransferTransactionUseCaseTest {
private val transactionRepository: TransactionRepository = mockk()
private val useCase = CreateTransferTransactionUseCase(transactionRepository = transactionRepository)
private val userWalletId: UserWalletId = mockk()
private val network: Network = mockk()
private val transactionData: TransactionData.Uncompiled = mockk()
private val amount = Amount(blockchain = Blockchain.Ethereum, value = BigDecimal.ONE)
@BeforeEach
fun resetMocks() {
clearMocks(transactionRepository)
}
@ParameterizedTest
@ProvideTestModels
fun `rejected destinations`(model: TestModel) = runTest {
// Act
val actual = useCase(
amount = amount,
memo = null,
destination = model.destination,
userWalletId = userWalletId,
network = network,
)
// Assert
assertThat(actual.leftOrNull()).isInstanceOf(IllegalArgumentException::class.java)
coVerify(exactly = 0) {
transactionRepository.createTransferTransaction(
amount = any(),
fee = any(),
memo = any(),
nonce = any(),
destination = any(),
userWalletId = any(),
network = any(),
)
}
}
@Test
fun `GIVEN regular recipient WHEN invoke THEN transaction is built`() = runTest {
// Arrange
val destination = "0xfc9013965447f804042a03ae4b98130a8c300a2f"
coEvery {
transactionRepository.createTransferTransaction(
amount = amount,
fee = null,
memo = null,
nonce = null,
destination = destination,
userWalletId = userWalletId,
network = network,
)
} returns transactionData
// Act
val actual = useCase(
amount = amount,
memo = null,
destination = destination,
userWalletId = userWalletId,
network = network,
)
// Assert
assertThat(actual.getOrNull()).isEqualTo(transactionData)
}
internal data class TestModel(val destination: String)
private fun provideTestModels() = listOf(
TestModel(destination = ""),
TestModel(destination = " "),
TestModel(destination = "0x0000000000000000000000000000000000000000"),
TestModel(destination = "0x000000000000000000000000000000000000dEaD"),
)
}

View file

@ -129,6 +129,42 @@ internal class ValidateWalletAddressUseCaseTest {
assertThat(result.leftOrNull()).isEqualTo(AddressValidation.Error.InvalidAddress) assertThat(result.leftOrNull()).isEqualTo(AddressValidation.Error.InvalidAddress)
} }
@Test
fun `GIVEN zero address WHEN invoke THEN returns InvalidAddress`() = runTest {
val address = "0x0000000000000000000000000000000000000000"
val senderAddresses = listOf(senderAddress("0xSender"))
val result = useCase(userWalletId, network, address, senderAddresses)
assertThat(result.leftOrNull()).isEqualTo(AddressValidation.Error.InvalidAddress)
}
@Test
fun `GIVEN dead address WHEN invoke THEN returns InvalidAddress`() = runTest {
val address = "0x000000000000000000000000000000000000dEaD"
val senderAddresses = listOf(senderAddress("0xSender"))
val result = useCase(userWalletId, network, address, senderAddresses)
assertThat(result.leftOrNull()).isEqualTo(AddressValidation.Error.InvalidAddress)
}
@Test
fun `GIVEN name resolved to dead address WHEN invoke THEN returns InvalidAddress`() = runTest {
val address = "burned.eth"
val resolvedAddress = "0x000000000000000000000000000000000000dEaD"
val senderAddresses = listOf(senderAddress("0xSender"))
coEvery { walletManagersFacade.checkSelfSendAvailability(userWalletId, network) } returns false
coEvery { repository.validateAddress(userWalletId, network, address) } returns false
coEvery { repository.resolveAddress(userWalletId, network, address) } returns
ResolveAddressResult.Resolved(resolvedAddress)
val result = useCase(userWalletId, network, address, senderAddresses)
assertThat(result.leftOrNull()).isEqualTo(AddressValidation.Error.InvalidAddress)
}
@Test @Test
fun `GIVEN valid XRP X-address WHEN invoke THEN returns ValidXAddress`() = runTest { fun `GIVEN valid XRP X-address WHEN invoke THEN returns ValidXAddress`() = runTest {
val xAddress = "X7AcgcsBL4L51nv2theWPZRMcGF37HeMBCFMDcaVEEF8Y3q" val xAddress = "X7AcgcsBL4L51nv2theWPZRMcGF37HeMBCFMDcaVEEF8Y3q"

View file

@ -0,0 +1,151 @@
package com.tangem.domain.transaction.usecase.gasless
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.math.BigDecimal
/** Unit tests for [CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients]. */
internal class CreateAndSendGaslessRecipientValidationTest {
private val blockchain = Blockchain.Ethereum
private val tokenContract = "0xdac17f958d2ee523a2206206994597c13d831ec7"
private val recipient = "0xfc9013965447f804042a03ae4b98130a8c300a2f"
private val yieldModule = "0x3a1f7e2c9b4d5e6f80912a3b4c5d6e7f8091a2b3"
private val zeroAddress = "0x0000000000000000000000000000000000000000"
private val deadAddress = "0x000000000000000000000000000000000000dEaD"
private val amount = Amount(
token = Token(symbol = "USDT", contractAddress = tokenContract, decimals = 6),
value = BigDecimal("994"),
)
private fun uncompiled(destinationAddress: String, callData: SmartContractCallData?) = TransactionData.Uncompiled(
amount = amount,
fee = null,
sourceAddress = "0x7f56aab66955bc02cc6b2870d4cddc12b0221c55",
destinationAddress = destinationAddress,
extras = callData?.let { EthereumTransactionExtras(callData = it) },
contractAddress = tokenContract,
)
@Test
fun `GIVEN valid ERC20 transfer WHEN validateTransactionRecipients THEN passes`() {
// Arrange
val txData = uncompiled(
destinationAddress = recipient,
callData = TransferERC20TokenCallData(destination = recipient, amount = amount),
)
// Act & Assert — no exception
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
}
@Test
fun `GIVEN blank recipient WHEN validateTransactionRecipients THEN throws`() {
// Arrange
val txData = uncompiled(
destinationAddress = "",
callData = TransferERC20TokenCallData(destination = "", amount = amount),
)
// Act & Assert
assertThrows<IllegalArgumentException> {
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
}
}
@Test
fun `GIVEN zero address recipient WHEN validateTransactionRecipients THEN throws`() {
// Arrange
val txData = uncompiled(
destinationAddress = zeroAddress,
callData = TransferERC20TokenCallData(destination = zeroAddress, amount = amount),
)
// Act & Assert
assertThrows<IllegalArgumentException> {
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
}
}
@Test
fun `GIVEN zero address inside call data only WHEN validateTransactionRecipients THEN throws`() {
// Arrange
val txData = uncompiled(
destinationAddress = tokenContract,
callData = TransferERC20TokenCallData(destination = zeroAddress, amount = amount),
)
// Act & Assert
assertThrows<IllegalArgumentException> {
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
}
}
@Test
fun `GIVEN dead address recipient WHEN validateTransactionRecipients THEN throws`() {
// Arrange — the dead address is well-formed and non-zero, so every other check accepts it
val txData = uncompiled(
destinationAddress = deadAddress,
callData = TransferERC20TokenCallData(destination = deadAddress, amount = amount),
)
// Act & Assert
assertThrows<IllegalArgumentException> {
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
}
}
@Test
fun `GIVEN dead address inside yield supply send call data WHEN validateTransactionRecipients THEN throws`() {
// Arrange — for a yield-supply send the recipient lives in the call data
val txData = uncompiled(
destinationAddress = yieldModule,
callData = EthereumYieldSupplySendCallData(
tokenContractAddress = tokenContract,
destinationAddress = deadAddress,
amount = amount,
),
)
// Act & Assert
assertThrows<IllegalArgumentException> {
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
}
}
@Test
fun `GIVEN valid yield supply send WHEN validateTransactionRecipients THEN passes`() {
// Arrange
val txData = uncompiled(
destinationAddress = yieldModule,
callData = EthereumYieldSupplySendCallData(
tokenContractAddress = tokenContract,
destinationAddress = recipient,
amount = amount,
),
)
// Act & Assert — no exception
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
}
@Test
fun `GIVEN missing call data WHEN validateTransactionRecipients THEN throws`() {
// Arrange
val txData = uncompiled(destinationAddress = recipient, callData = null)
// Act & Assert
assertThrows<IllegalStateException> {
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
}
}
}

View file

@ -364,6 +364,8 @@ internal class SendConfirmModel @Inject constructor(
private fun verifyAndSendTransaction() { private fun verifyAndSendTransaction() {
val amountValue = amountState?.amountTextField?.cryptoAmount?.value ?: return val amountValue = amountState?.amountTextField?.cryptoAmount?.value ?: return
// A blank recipient is deliberately not filtered out here: returning early would leave `isSending` on
// forever. It is rejected while the transaction is built, so it surfaces as the regular error alert.
val destination = destinationUM?.addressTextField?.actualAddress ?: return val destination = destinationUM?.addressTextField?.actualAddress ?: return
val memo = destinationUM?.memoTextField?.value val memo = destinationUM?.memoTextField?.value
val fee = feeUMV2?.selectedFeeItem?.fee val fee = feeUMV2?.selectedFeeItem?.fee

View file

@ -26,6 +26,15 @@ internal class SendDestinationAlertFactory @Inject constructor(
) )
} }
fun showUnrecognizedQrCodeAlert() {
messageSender.send(
DialogMessage(
title = resourceReference(id = R.string.qr_scanner_error_unrecognized_title),
message = resourceReference(id = R.string.qr_scanner_error_unrecognized_message),
),
)
}
fun showRecipientBackupErrorAlert(onContactSupport: () -> Unit) { fun showRecipientBackupErrorAlert(onContactSupport: () -> Unit) {
messageSender.send( messageSender.send(
DialogMessage( DialogMessage(

View file

@ -33,6 +33,7 @@ import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.tokens.GetNetworkAddressesUseCase import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.error.AddressValidation import com.tangem.domain.transaction.error.AddressValidation
import com.tangem.domain.transaction.error.AddressValidationResult
import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase
import com.tangem.domain.transaction.usecase.IsSelfSendAvailableUseCase import com.tangem.domain.transaction.usecase.IsSelfSendAvailableUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
@ -314,8 +315,14 @@ internal class SendDestinationModel @Inject constructor(
.launchIn(modelScope) .launchIn(modelScope)
} }
private fun onQrCodeScanned(address: String) { private fun onQrCodeScanned(qrCode: String) {
val parsedQrCode = parseQrCodeUseCase(address, cryptoCurrency).getOrNull() ?: return val parsedQrCode = parseQrCodeUseCase(qrCode, cryptoCurrency).getOrNull()
if (parsedQrCode == null || parsedQrCode.address.isBlank()) {
sendDestinationAlertFactory.showUnrecognizedQrCodeAlert()
return
}
_uiState.update( _uiState.update(
SendDestinationPredefinedStateTransformer( SendDestinationPredefinedStateTransformer(
address = parsedQrCode.address, address = parsedQrCode.address,
@ -443,6 +450,8 @@ internal class SendDestinationModel @Inject constructor(
allowSelfSend = params.isAllowSelfSend, allowSelfSend = params.isAllowSelfSend,
) )
notifyIfQrCodeUnrecognized(type = type, addressValidationResult = addressValidationResult)
if (addressValidationResult.isRight()) { if (addressValidationResult.isRight()) {
val problematicWalletId = resolveBackupProblematicWallet(address) val problematicWalletId = resolveBackupProblematicWallet(address)
if (problematicWalletId != null) { if (problematicWalletId != null) {
@ -503,6 +512,21 @@ internal class SendDestinationModel @Inject constructor(
}.saveIn(validationJobHolder) }.saveIn(validationJobHolder)
} }
/**
* The scanned code carries nothing this network accepts as a recipient a link, an address of another network,
* a burn address. Reported as an alert, because the inline field error is easy to miss right after the camera
* closes, and a QR that "filled something in" reads as a successfully scanned recipient.
*/
private fun notifyIfQrCodeUnrecognized(
type: EnterAddressSource?,
addressValidationResult: AddressValidationResult,
) {
if (type != EnterAddressSource.QRCode) return
if (addressValidationResult.leftOrNull() != AddressValidation.Error.InvalidAddress) return
sendDestinationAlertFactory.showUnrecognizedQrCodeAlert()
}
private fun recognizeContact(type: EnterAddressSource?, isValidAddress: Boolean, address: String) { private fun recognizeContact(type: EnterAddressSource?, isValidAddress: Boolean, address: String) {
if (type == null || type == EnterAddressSource.Contact) return if (type == null || type == EnterAddressSource.Contact) return
val contact = if (isValidAddress) findContactByAddress(address) else null val contact = if (isValidAddress) findContactByAddress(address) else null

View file

@ -155,6 +155,11 @@ private fun LazyListScope.addressItem(
onAddressChange(it, EnterAddressSource.PasteButton) onAddressChange(it, EnterAddressSource.PasteButton)
} }
}, },
onClearClick = {
GlobalMultipleClickPreventer.processEvent {
onAddressChange("", EnterAddressSource.InputField)
}
},
onQrCodeClick = onQrCodeClick, onQrCodeClick = onQrCodeClick,
isError = isError, isError = isError,
isLoading = isValidating, isLoading = isValidating,

View file

@ -87,7 +87,7 @@ internal fun TextFieldWithPaste(
.align(CenterVertically), .align(CenterVertically),
) { ) {
CrossIcon( CrossIcon(
onClick = onPasteClick, onClick = { onPasteClick("") },
modifier = Modifier.testTag(SendAddressScreenTestTags.DESTINATION_TAG_CLEAR_TEXT_FIELD_BUTTON), modifier = Modifier.testTag(SendAddressScreenTestTags.DESTINATION_TAG_CLEAR_TEXT_FIELD_BUTTON),
) )
} }

View file

@ -26,6 +26,7 @@ import com.tangem.domain.models.network.CryptoCurrencyAddress
import com.tangem.domain.models.network.Network 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.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.qrscanning.models.QrResult
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.tokens.GetNetworkAddressesUseCase import com.tangem.domain.tokens.GetNetworkAddressesUseCase
@ -286,7 +287,7 @@ internal class SendDestinationModelTest {
inner class QrScan { inner class QrScan {
@Test @Test
fun `GIVEN unparseable QR WHEN scanned THEN do NOT validate`() = runTest { fun `GIVEN unparseable QR WHEN scanned THEN do NOT validate AND show unrecognized alert`() = runTest {
// Arrange // Arrange
val qrFlow = MutableStateFlow("rawQr") val qrFlow = MutableStateFlow("rawQr")
every { listenToQrScanningUseCase(any()) } returns qrFlow.right() every { listenToQrScanningUseCase(any()) } returns qrFlow.right()
@ -307,6 +308,83 @@ internal class SendDestinationModelTest {
any() any()
) )
} }
verify(exactly = 1) { sendDestinationAlertFactory.showUnrecognizedQrCodeAlert() }
}
@Test
fun `GIVEN QR without address WHEN scanned THEN keep entered recipient AND show unrecognized alert`() =
runTest {
// Arrange — the QR parses but yields no address (e.g. an ERC-681 URI for another token)
val qrFlow = MutableSharedFlow<String>(extraBufferCapacity = 1)
every { listenToQrScanningUseCase(any()) } returns qrFlow.right()
every { parseQrCodeUseCase("rawQr", cryptoCurrency) } returns QrResult(address = "").right()
val sut = buildModel(initialState = contentState(address = "0xEntered"))
advanceUntilIdle()
// Act
qrFlow.tryEmit("rawQr")
advanceUntilIdle()
// Assert — a QR with nothing usable must not wipe what the user already has
assertThat(content(sut).addressTextField.value).isEqualTo("0xEntered")
verify(exactly = 1) { sendDestinationAlertFactory.showUnrecognizedQrCodeAlert() }
}
@Test
fun `GIVEN QR address invalid for network WHEN scanned THEN show unrecognized alert`() = runTest {
// Arrange — a scanned link or a foreign-network address the current network rejects
val qrFlow = MutableStateFlow("rawQr")
every { listenToQrScanningUseCase(any()) } returns qrFlow.right()
every { parseQrCodeUseCase("rawQr", cryptoCurrency) } returns QrResult(address = "exchange.com").right()
coEvery {
validateWalletAddressUseCase(any(), any(), eq("exchange.com"), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Error.InvalidAddress.left()
// Act
buildModel()
advanceUntilIdle()
// Assert
verify(exactly = 1) { sendDestinationAlertFactory.showUnrecognizedQrCodeAlert() }
}
@Test
fun `GIVEN QR address already in wallet WHEN scanned THEN do NOT show unrecognized alert`() = runTest {
// Arrange — self-send is reported inline on the field, the QR itself was read fine
val qrFlow = MutableStateFlow("rawQr")
every { listenToQrScanningUseCase(any()) } returns qrFlow.right()
every { parseQrCodeUseCase("rawQr", cryptoCurrency) } returns QrResult(address = "0xSelf").right()
coEvery {
validateWalletAddressUseCase(any(), any(), eq("0xSelf"), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Error.AddressInWallet.left()
// Act
buildModel()
advanceUntilIdle()
// Assert
verify(exactly = 0) { sendDestinationAlertFactory.showUnrecognizedQrCodeAlert() }
}
@Test
fun `GIVEN QR with valid address WHEN scanned THEN fill recipient without alert`() = runTest {
// Arrange
val qrFlow = MutableStateFlow("rawQr")
every { listenToQrScanningUseCase(any()) } returns qrFlow.right()
every { parseQrCodeUseCase("rawQr", cryptoCurrency) } returns
QrResult(address = "0xValid", memo = "42").right()
coEvery {
validateWalletAddressUseCase(any(), any(), eq("0xValid"), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right()
val sut = buildModel(initialState = contentState(address = "", memo = ""))
// Act
advanceUntilIdle()
// Assert
assertThat(content(sut).addressTextField.value).isEqualTo("0xValid")
assertThat(content(sut).memoTextField?.value).isEqualTo("42")
verify(exactly = 0) { sendDestinationAlertFactory.showUnrecognizedQrCodeAlert() }
} }
} }

View file

@ -5,7 +5,7 @@
# https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico # https://github.com/tangem/vico
tangemBlockchainSdk = "releases-6.1-1638" tangemBlockchainSdk = "releases-6.1-1647"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "releases-6.1-633" tangemCardSdk = "releases-6.1-633"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^