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

@ -5,6 +5,7 @@ import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.nft.models.NFTAsset
import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.models.network.isBurnAddress
import com.tangem.domain.models.wallet.UserWalletId
class CreateNFTTransferTransactionUseCase(
@ -23,6 +24,8 @@ class CreateNFTTransferTransactionUseCase(
userWalletId: UserWalletId,
network: Network,
) = Either.catch {
requireSpendableDestination(destinationAddress)
transactionRepository.createNFTTransferTransaction(
ownerAddress = ownerAddress,
nftAsset = nftAsset,
@ -46,6 +49,8 @@ class CreateNFTTransferTransactionUseCase(
userWalletId: UserWalletId,
network: Network,
) = Either.catch {
requireSpendableDestination(destinationAddress)
transactionRepository.createNFTTransferTransaction(
ownerAddress = ownerAddress,
nftAsset = nftAsset,
@ -56,4 +61,10 @@ class CreateNFTTransferTransactionUseCase(
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.domain.models.network.Network
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.models.network.isBurnAddress
import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigInteger
@ -31,6 +32,8 @@ class CreateTransferTransactionUseCase(
network: Network,
nonce: BigInteger? = null,
) = Either.catch {
requireSpendableDestination(destination)
transactionRepository.createTransferTransaction(
amount = amount,
fee = fee,
@ -54,6 +57,8 @@ class CreateTransferTransactionUseCase(
network: Network,
nonce: BigInteger? = null,
) = Either.catch {
requireSpendableDestination(destination)
transactionRepository.createTransferTransaction(
amount = amount,
memo = memo,
@ -64,4 +69,17 @@ class CreateTransferTransactionUseCase(
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.error.AddressValidation
import com.tangem.domain.transaction.error.AddressValidationResult
import com.tangem.domain.models.network.isBurnAddress
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.lib.crypto.BlockchainUtils
@ -60,6 +61,9 @@ class ValidateWalletAddressUseCase(
allowSelfSend: Boolean,
isCurrentAddress: (String) -> Boolean,
): 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 isSelfSendAvailable = walletManagersFacade.checkSelfSendAvailability(userWalletId, network)
@ -76,7 +80,10 @@ class ValidateWalletAddressUseCase(
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()
} else {
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.models.EIP7702AuthorizationData
import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.extensions.Result
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.GaslessTransactionData
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.models.network.isBurnAddress
import com.tangem.domain.walletmanager.WalletManagersFacade
import java.math.BigInteger
@ -98,6 +100,11 @@ class CreateAndSendGaslessTransactionUseCase(
"does not support gasless transactions",
)
validateTransactionRecipients(
blockchain = walletManager.wallet.blockchain,
transactionData = transactionData,
)
val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress)
val mainTxGasLimit = fee.mainTransactionGasLimit
@ -462,5 +469,38 @@ class CreateAndSendGaslessTransactionUseCase(
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)
}
@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
fun `GIVEN valid XRP X-address WHEN invoke THEN returns ValidXAddress`() = runTest {
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)
}
}
}