Updated on 2026-08-14
This commit is contained in:
parent
cd266b50f5
commit
00126b9d36
21 changed files with 711 additions and 16 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
target = inner,
|
// rejected anyway — the error would only show up after the user confirms the warning.
|
||||||
unsupportedParams = classified.unsupportedParams,
|
is QrSendTarget.Error -> inner
|
||||||
)
|
else -> QrSendTarget.Warning(
|
||||||
|
target = inner,
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ^
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue