Updated on 2026-08-14
This commit is contained in:
parent
cd266b50f5
commit
00126b9d36
21 changed files with 711 additions and 16 deletions
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue