diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt index b216e3991a..6737614db1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt @@ -45,6 +45,8 @@ import kotlinx.coroutines.delay * @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 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 modifier Optional modifier to apply to the composable * @param singleLine Whether the text field should be constrained to a single line (default: false) @@ -64,6 +66,7 @@ fun InputRowRecipient( placeholder: TextReference, onValueChange: (String) -> Unit, onPasteClick: (String) -> Unit, + onClearClick: () -> Unit, onQrCodeClick: () -> Unit, modifier: Modifier = Modifier, singleLine: Boolean = false, @@ -121,7 +124,7 @@ fun InputRowRecipient( .testTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD), ) CrossIcon( - onClick = onPasteClick, + onClick = onClearClick, modifier = Modifier .align(CenterVertically) .padding(start = TangemTheme.dimens.spacing8) @@ -258,6 +261,7 @@ private fun InputRowRecipientPreview( showDivider = true, onValueChange = {}, onPasteClick = {}, + onClearClick = {}, onQrCodeClick = {}, modifier = Modifier.background(TangemTheme.colors.background.primary), resolvedAddress = value.resolvedAddress, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt index 98b7616634..5f3b6af5db 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt @@ -90,7 +90,7 @@ fun PasteButton( } @Composable -fun CrossIcon(onClick: (String) -> Unit, modifier: Modifier = Modifier) { +fun CrossIcon(onClick: () -> Unit, modifier: Modifier = Modifier) { Icon( painter = painterResource(id = R.drawable.ic_close_24), tint = TangemTheme.colors.icon.informative, @@ -100,7 +100,7 @@ fun CrossIcon(onClick: (String) -> Unit, modifier: Modifier = Modifier) { .clickable( interactionSource = remember { MutableInteractionSource() }, indication = ripple(radius = TangemTheme.dimens.radius12), - onClick = { onClick("") }, + onClick = onClick, ), ) } diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/network/BurnAddress.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/network/BurnAddress.kt new file mode 100644 index 0000000000..f65da6837d --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/network/BurnAddress.kt @@ -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 +} \ No newline at end of file diff --git a/domain/models/src/test/kotlin/com/tangem/domain/models/network/BurnAddressTest.kt b/domain/models/src/test/kotlin/com/tangem/domain/models/network/BurnAddressTest.kt new file mode 100644 index 0000000000..13c0910709 --- /dev/null +++ b/domain/models/src/test/kotlin/com/tangem/domain/models/network/BurnAddressTest.kt @@ -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), + ) +} \ No newline at end of file diff --git a/domain/qr-scanning/build.gradle.kts b/domain/qr-scanning/build.gradle.kts index 04745af70f..7c439e5707 100644 --- a/domain/qr-scanning/build.gradle.kts +++ b/domain/qr-scanning/build.gradle.kts @@ -28,4 +28,8 @@ dependencies { api(projects.domain.models) api(projects.domain.qrScanning.models) // endregion + + // region Test + testImplementation(projects.test.core) + // endregion } \ No newline at end of file diff --git a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt index 4156a83e1f..5768c5470c 100644 --- a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt +++ b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.isBurnAddress import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.networks.repository.NetworksRepository @@ -52,14 +53,19 @@ class ResolveQrSendTargetsUseCase( val classified = qrScanningEventsRepository.classify(qrCode, allCurrencies) 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) { is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri) is ClassifiedQrContent.Error -> QrSendTarget.Error(classified) is ClassifiedQrContent.PlainAddress -> resolveAddressTarget( + qrCode = qrCode, address = classified.address, amount = null, memo = null, @@ -67,6 +73,7 @@ class ResolveQrSendTargetsUseCase( portfolioIndex = portfolioIndex, ) is ClassifiedQrContent.PaymentUri -> resolveAddressTarget( + qrCode = qrCode, address = classified.address, amount = classified.amount, memo = classified.memo, @@ -74,22 +81,32 @@ class ResolveQrSendTargetsUseCase( portfolioIndex = portfolioIndex, ) is ClassifiedQrContent.PaymentUriWarning -> { - val inner = resolve(classified.paymentUri, portfolioIndex) - QrSendTarget.Warning( - target = inner, - unsupportedParams = classified.unsupportedParams, - ) + when (val inner = resolve(qrCode, classified.paymentUri, portfolioIndex)) { + // Asking whether to continue with unsupported parameters makes no sense for a QR that is + // rejected anyway — the error would only show up after the user confirms the warning. + is QrSendTarget.Error -> inner + else -> QrSendTarget.Warning( + target = inner, + unsupportedParams = classified.unsupportedParams, + ) + } } } } + @Suppress("LongParameterList") private suspend fun resolveAddressTarget( + qrCode: String, address: String, amount: BigDecimal?, memo: String?, matchingCurrencies: List, portfolioIndex: PortfolioIndex, ): QrSendTarget { + if (address.isBurnAddress()) { + return QrSendTarget.Error(ClassifiedQrContent.Error.Unrecognized(raw = qrCode)) + } + val ownAddressNetworks = findOwnAddressNetworks(address, matchingCurrencies, portfolioIndex) val walletGroups = buildWalletGroups(matchingCurrencies, portfolioIndex, ownAddressNetworks) diff --git a/domain/qr-scanning/src/test/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCaseTest.kt b/domain/qr-scanning/src/test/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCaseTest.kt new file mode 100644 index 0000000000..3530b75c92 --- /dev/null +++ b/domain/qr-scanning/src/test/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCaseTest.kt @@ -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" + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateNFTTransferTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateNFTTransferTransactionUseCase.kt index dfbb0e9fd2..1a3eb01e83 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateNFTTransferTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateNFTTransferTransactionUseCase.kt @@ -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" } + } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCase.kt index 69c2c53ab5..a3d91137ff 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCase.kt @@ -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" } + } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletAddressUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletAddressUseCase.kt index ab8e62b026..d3435c8895 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletAddressUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletAddressUseCase.kt @@ -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() diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt index cb448b6085..b328ee6ddb 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -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 + } } } \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCaseTest.kt new file mode 100644 index 0000000000..28beb77384 --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCaseTest.kt @@ -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"), + ) +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/ValidateWalletAddressUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/ValidateWalletAddressUseCaseTest.kt index 515a82dc56..b780f3938b 100644 --- a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/ValidateWalletAddressUseCaseTest.kt +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/ValidateWalletAddressUseCaseTest.kt @@ -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" diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessRecipientValidationTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessRecipientValidationTest.kt new file mode 100644 index 0000000000..93eb1e0d10 --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessRecipientValidationTest.kt @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData) + } + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt index b1f2ec0625..297708f8db 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt @@ -364,6 +364,8 @@ internal class SendConfirmModel @Inject constructor( private fun verifyAndSendTransaction() { 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 memo = destinationUM?.memoTextField?.value val fee = feeUMV2?.selectedFeeItem?.fee diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/SendDestinationAlertFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/SendDestinationAlertFactory.kt index a1d7e10c78..c32c1a1d4a 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/SendDestinationAlertFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/SendDestinationAlertFactory.kt @@ -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) { messageSender.send( DialogMessage( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt index 055a95a067..b05b3175a8 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt @@ -33,6 +33,7 @@ import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase import com.tangem.domain.tokens.GetNetworkAddressesUseCase 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.IsSelfSendAvailableUseCase import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase @@ -314,8 +315,14 @@ internal class SendDestinationModel @Inject constructor( .launchIn(modelScope) } - private fun onQrCodeScanned(address: String) { - val parsedQrCode = parseQrCodeUseCase(address, cryptoCurrency).getOrNull() ?: return + private fun onQrCodeScanned(qrCode: String) { + val parsedQrCode = parseQrCodeUseCase(qrCode, cryptoCurrency).getOrNull() + + if (parsedQrCode == null || parsedQrCode.address.isBlank()) { + sendDestinationAlertFactory.showUnrecognizedQrCodeAlert() + return + } + _uiState.update( SendDestinationPredefinedStateTransformer( address = parsedQrCode.address, @@ -443,6 +450,8 @@ internal class SendDestinationModel @Inject constructor( allowSelfSend = params.isAllowSelfSend, ) + notifyIfQrCodeUnrecognized(type = type, addressValidationResult = addressValidationResult) + if (addressValidationResult.isRight()) { val problematicWalletId = resolveBackupProblematicWallet(address) if (problematicWalletId != null) { @@ -503,6 +512,21 @@ internal class SendDestinationModel @Inject constructor( }.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) { if (type == null || type == EnterAddressSource.Contact) return val contact = if (isValidAddress) findContactByAddress(address) else null diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt index 13923455a4..8ec4733cb6 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt @@ -155,6 +155,11 @@ private fun LazyListScope.addressItem( onAddressChange(it, EnterAddressSource.PasteButton) } }, + onClearClick = { + GlobalMultipleClickPreventer.processEvent { + onAddressChange("", EnterAddressSource.InputField) + } + }, onQrCodeClick = onQrCodeClick, isError = isError, isLoading = isValidating, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/TextFieldWithPaste.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/TextFieldWithPaste.kt index 6206bdb1b0..5b62a511c6 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/TextFieldWithPaste.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/TextFieldWithPaste.kt @@ -87,7 +87,7 @@ internal fun TextFieldWithPaste( .align(CenterVertically), ) { CrossIcon( - onClick = onPasteClick, + onClick = { onPasteClick("") }, modifier = Modifier.testTag(SendAddressScreenTestTags.DESTINATION_TAG_CLEAR_TEXT_FIELD_BUTTON), ) } diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt index acb56a5340..6095a958b6 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt @@ -26,6 +26,7 @@ import com.tangem.domain.models.network.CryptoCurrencyAddress import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.TxInfo 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.ParseQrCodeUseCase import com.tangem.domain.tokens.GetNetworkAddressesUseCase @@ -286,7 +287,7 @@ internal class SendDestinationModelTest { inner class QrScan { @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 val qrFlow = MutableStateFlow("rawQr") every { listenToQrScanningUseCase(any()) } returns qrFlow.right() @@ -307,6 +308,83 @@ internal class SendDestinationModelTest { 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(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>(), 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>(), 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>(), 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() } } } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index e238c171bd..84d77f1d80 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # 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 tangemCardSdk = "releases-6.1-633" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^