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 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.qrScanning.models)
|
||||
// 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.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(
|
||||
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<CryptoCurrency>,
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.domain.models.network.Network
|
||||
import com.tangem.domain.transaction.TransactionRepository
|
||||
import com.tangem.domain.models.network.isBurnAddress
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
class CreateNFTTransferTransactionUseCase(
|
||||
|
|
@ -23,6 +24,8 @@ class CreateNFTTransferTransactionUseCase(
|
|||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
) = Either.catch {
|
||||
requireSpendableDestination(destinationAddress)
|
||||
|
||||
transactionRepository.createNFTTransferTransaction(
|
||||
ownerAddress = ownerAddress,
|
||||
nftAsset = nftAsset,
|
||||
|
|
@ -46,6 +49,8 @@ class CreateNFTTransferTransactionUseCase(
|
|||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
) = Either.catch {
|
||||
requireSpendableDestination(destinationAddress)
|
||||
|
||||
transactionRepository.createNFTTransferTransaction(
|
||||
ownerAddress = ownerAddress,
|
||||
nftAsset = nftAsset,
|
||||
|
|
@ -56,4 +61,10 @@ class CreateNFTTransferTransactionUseCase(
|
|||
network = network,
|
||||
)
|
||||
}
|
||||
|
||||
/** Same barrier as on the coin/token transfer path, see [CreateTransferTransactionUseCase]. */
|
||||
private fun requireSpendableDestination(destinationAddress: String) {
|
||||
require(destinationAddress.isNotBlank()) { "Transfers with a blank destination are not allowed" }
|
||||
require(!destinationAddress.isBurnAddress()) { "Transfers to a burn address are not allowed" }
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import com.tangem.blockchain.common.Amount
|
|||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.transaction.TransactionRepository
|
||||
import com.tangem.domain.models.network.isBurnAddress
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import java.math.BigInteger
|
||||
|
||||
|
|
@ -31,6 +32,8 @@ class CreateTransferTransactionUseCase(
|
|||
network: Network,
|
||||
nonce: BigInteger? = null,
|
||||
) = Either.catch {
|
||||
requireSpendableDestination(destination)
|
||||
|
||||
transactionRepository.createTransferTransaction(
|
||||
amount = amount,
|
||||
fee = fee,
|
||||
|
|
@ -54,6 +57,8 @@ class CreateTransferTransactionUseCase(
|
|||
network: Network,
|
||||
nonce: BigInteger? = null,
|
||||
) = Either.catch {
|
||||
requireSpendableDestination(destination)
|
||||
|
||||
transactionRepository.createTransferTransaction(
|
||||
amount = amount,
|
||||
memo = memo,
|
||||
|
|
@ -64,4 +69,17 @@ class CreateTransferTransactionUseCase(
|
|||
network = network,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipient typed on the send screen is already rejected by [ValidateWalletAddressUseCase], but a send
|
||||
* deeplink jumps straight to the confirmation step, bypassing that screen — hence the check here, at the single
|
||||
* point every transfer is built at.
|
||||
*
|
||||
* A blank recipient is rejected together with the burn ones: it degrades into the zero address once the call
|
||||
* data is encoded.
|
||||
*/
|
||||
private fun requireSpendableDestination(destination: String) {
|
||||
require(destination.isNotBlank()) { "Transfers with a blank destination are not allowed" }
|
||||
require(!destination.isBurnAddress()) { "Transfers to a burn address are not allowed" }
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import com.tangem.domain.models.network.NetworkAddress
|
|||
import com.tangem.domain.transaction.WalletAddressServiceRepository
|
||||
import com.tangem.domain.transaction.error.AddressValidation
|
||||
import com.tangem.domain.transaction.error.AddressValidationResult
|
||||
import com.tangem.domain.models.network.isBurnAddress
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
|
|
@ -60,6 +61,9 @@ class ValidateWalletAddressUseCase(
|
|||
allowSelfSend: Boolean,
|
||||
isCurrentAddress: (String) -> Boolean,
|
||||
): AddressValidationResult {
|
||||
// A burn address is well-formed, so every check below would accept it.
|
||||
if (address.isBurnAddress()) return AddressValidation.Error.InvalidAddress.left()
|
||||
|
||||
val decodedXAddress = BlockchainUtils.decodeRippleXAddress(address, network.rawId)
|
||||
val isSelfSendAvailable = walletManagersFacade.checkSelfSendAvailability(userWalletId, network)
|
||||
|
||||
|
|
@ -76,7 +80,10 @@ class ValidateWalletAddressUseCase(
|
|||
address = addressToValidate,
|
||||
)
|
||||
|
||||
if (resolveAddressResult is ResolveAddressResult.Resolved) {
|
||||
// A name may resolve to a burn address too, so the resolved one goes through the same blacklist.
|
||||
if (resolveAddressResult is ResolveAddressResult.Resolved &&
|
||||
!resolveAddressResult.address.isBurnAddress()
|
||||
) {
|
||||
AddressValidation.Success.ValidNamedAddress(resolveAddressResult.address).right()
|
||||
} else {
|
||||
AddressValidation.Error.InvalidAddress.left()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
|
|||
import com.tangem.blockchain.blockchains.ethereum.gasless.EthereumGaslessDataProvider
|
||||
import com.tangem.blockchain.blockchains.ethereum.models.EIP7702AuthorizationData
|
||||
import com.tangem.blockchain.common.*
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.extensions.Result
|
||||
import com.tangem.blockchain.extensions.formatHex
|
||||
|
|
@ -31,6 +32,7 @@ import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
|||
import com.tangem.domain.transaction.models.GaslessFeePlan
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.domain.models.network.isBurnAddress
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import java.math.BigInteger
|
||||
|
||||
|
|
@ -98,6 +100,11 @@ class CreateAndSendGaslessTransactionUseCase(
|
|||
"does not support gasless transactions",
|
||||
)
|
||||
|
||||
validateTransactionRecipients(
|
||||
blockchain = walletManager.wallet.blockchain,
|
||||
transactionData = transactionData,
|
||||
)
|
||||
|
||||
val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress)
|
||||
|
||||
val mainTxGasLimit = fee.mainTransactionGasLimit
|
||||
|
|
@ -462,5 +469,38 @@ class CreateAndSendGaslessTransactionUseCase(
|
|||
txData.contractAddress ?: error("supports only Token transaction with contract address")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Last line of defence before the meta-transaction is signed: the gasless path never reaches
|
||||
* `TransactionSender.send`, so the `TransactionValidator` guarding the regular EVM send never runs for it.
|
||||
* The user signs the payload himself, so neither the gasless service nor the chain can reject it afterwards.
|
||||
*/
|
||||
internal fun validateTransactionRecipients(
|
||||
blockchain: Blockchain,
|
||||
transactionData: TransactionData.Uncompiled,
|
||||
) {
|
||||
val destination = transactionData.destinationAddress
|
||||
require(blockchain.validateAddress(destination) && !destination.isBurnAddress()) {
|
||||
"Invalid destination address for a gasless transaction"
|
||||
}
|
||||
|
||||
val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData
|
||||
?: error("Ethereum call data is required")
|
||||
require(callData.validate(blockchain)) {
|
||||
"Invalid call data for a gasless transaction"
|
||||
}
|
||||
require(callData.recipientOrNull()?.isBurnAddress() != true) {
|
||||
"Burn address recipient in the call data of a gasless transaction"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipient encoded inside the call data, when it differs from the transaction's own destination.
|
||||
* Only the yield-supply send needs it: its `to` is the user's yield module.
|
||||
*/
|
||||
private fun SmartContractCallData.recipientOrNull(): String? = when (this) {
|
||||
is EthereumYieldSupplySendCallData -> destinationAddress
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package com.tangem.domain.transaction.usecase
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.TransactionRepository
|
||||
import com.tangem.test.core.ProvideTestModels
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import java.math.BigDecimal
|
||||
|
||||
/** Unit tests for the destination barrier of [CreateTransferTransactionUseCase]. */
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class CreateTransferTransactionUseCaseTest {
|
||||
|
||||
private val transactionRepository: TransactionRepository = mockk()
|
||||
private val useCase = CreateTransferTransactionUseCase(transactionRepository = transactionRepository)
|
||||
|
||||
private val userWalletId: UserWalletId = mockk()
|
||||
private val network: Network = mockk()
|
||||
private val transactionData: TransactionData.Uncompiled = mockk()
|
||||
|
||||
private val amount = Amount(blockchain = Blockchain.Ethereum, value = BigDecimal.ONE)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(transactionRepository)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun `rejected destinations`(model: TestModel) = runTest {
|
||||
// Act
|
||||
val actual = useCase(
|
||||
amount = amount,
|
||||
memo = null,
|
||||
destination = model.destination,
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(actual.leftOrNull()).isInstanceOf(IllegalArgumentException::class.java)
|
||||
coVerify(exactly = 0) {
|
||||
transactionRepository.createTransferTransaction(
|
||||
amount = any(),
|
||||
fee = any(),
|
||||
memo = any(),
|
||||
nonce = any(),
|
||||
destination = any(),
|
||||
userWalletId = any(),
|
||||
network = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN regular recipient WHEN invoke THEN transaction is built`() = runTest {
|
||||
// Arrange
|
||||
val destination = "0xfc9013965447f804042a03ae4b98130a8c300a2f"
|
||||
coEvery {
|
||||
transactionRepository.createTransferTransaction(
|
||||
amount = amount,
|
||||
fee = null,
|
||||
memo = null,
|
||||
nonce = null,
|
||||
destination = destination,
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
)
|
||||
} returns transactionData
|
||||
|
||||
// Act
|
||||
val actual = useCase(
|
||||
amount = amount,
|
||||
memo = null,
|
||||
destination = destination,
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(actual.getOrNull()).isEqualTo(transactionData)
|
||||
}
|
||||
|
||||
internal data class TestModel(val destination: String)
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
TestModel(destination = ""),
|
||||
TestModel(destination = " "),
|
||||
TestModel(destination = "0x0000000000000000000000000000000000000000"),
|
||||
TestModel(destination = "0x000000000000000000000000000000000000dEaD"),
|
||||
)
|
||||
}
|
||||
|
|
@ -129,6 +129,42 @@ internal class ValidateWalletAddressUseCaseTest {
|
|||
assertThat(result.leftOrNull()).isEqualTo(AddressValidation.Error.InvalidAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN zero address WHEN invoke THEN returns InvalidAddress`() = runTest {
|
||||
val address = "0x0000000000000000000000000000000000000000"
|
||||
val senderAddresses = listOf(senderAddress("0xSender"))
|
||||
|
||||
val result = useCase(userWalletId, network, address, senderAddresses)
|
||||
|
||||
assertThat(result.leftOrNull()).isEqualTo(AddressValidation.Error.InvalidAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN dead address WHEN invoke THEN returns InvalidAddress`() = runTest {
|
||||
val address = "0x000000000000000000000000000000000000dEaD"
|
||||
val senderAddresses = listOf(senderAddress("0xSender"))
|
||||
|
||||
val result = useCase(userWalletId, network, address, senderAddresses)
|
||||
|
||||
assertThat(result.leftOrNull()).isEqualTo(AddressValidation.Error.InvalidAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN name resolved to dead address WHEN invoke THEN returns InvalidAddress`() = runTest {
|
||||
val address = "burned.eth"
|
||||
val resolvedAddress = "0x000000000000000000000000000000000000dEaD"
|
||||
val senderAddresses = listOf(senderAddress("0xSender"))
|
||||
|
||||
coEvery { walletManagersFacade.checkSelfSendAvailability(userWalletId, network) } returns false
|
||||
coEvery { repository.validateAddress(userWalletId, network, address) } returns false
|
||||
coEvery { repository.resolveAddress(userWalletId, network, address) } returns
|
||||
ResolveAddressResult.Resolved(resolvedAddress)
|
||||
|
||||
val result = useCase(userWalletId, network, address, senderAddresses)
|
||||
|
||||
assertThat(result.leftOrNull()).isEqualTo(AddressValidation.Error.InvalidAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid XRP X-address WHEN invoke THEN returns ValidXAddress`() = runTest {
|
||||
val xAddress = "X7AcgcsBL4L51nv2theWPZRMcGF37HeMBCFMDcaVEEF8Y3q"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
package com.tangem.domain.transaction.usecase.gasless
|
||||
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
|
||||
import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import java.math.BigDecimal
|
||||
|
||||
/** Unit tests for [CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients]. */
|
||||
internal class CreateAndSendGaslessRecipientValidationTest {
|
||||
|
||||
private val blockchain = Blockchain.Ethereum
|
||||
private val tokenContract = "0xdac17f958d2ee523a2206206994597c13d831ec7"
|
||||
private val recipient = "0xfc9013965447f804042a03ae4b98130a8c300a2f"
|
||||
private val yieldModule = "0x3a1f7e2c9b4d5e6f80912a3b4c5d6e7f8091a2b3"
|
||||
private val zeroAddress = "0x0000000000000000000000000000000000000000"
|
||||
private val deadAddress = "0x000000000000000000000000000000000000dEaD"
|
||||
|
||||
private val amount = Amount(
|
||||
token = Token(symbol = "USDT", contractAddress = tokenContract, decimals = 6),
|
||||
value = BigDecimal("994"),
|
||||
)
|
||||
|
||||
private fun uncompiled(destinationAddress: String, callData: SmartContractCallData?) = TransactionData.Uncompiled(
|
||||
amount = amount,
|
||||
fee = null,
|
||||
sourceAddress = "0x7f56aab66955bc02cc6b2870d4cddc12b0221c55",
|
||||
destinationAddress = destinationAddress,
|
||||
extras = callData?.let { EthereumTransactionExtras(callData = it) },
|
||||
contractAddress = tokenContract,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid ERC20 transfer WHEN validateTransactionRecipients THEN passes`() {
|
||||
// Arrange
|
||||
val txData = uncompiled(
|
||||
destinationAddress = recipient,
|
||||
callData = TransferERC20TokenCallData(destination = recipient, amount = amount),
|
||||
)
|
||||
|
||||
// Act & Assert — no exception
|
||||
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blank recipient WHEN validateTransactionRecipients THEN throws`() {
|
||||
// Arrange
|
||||
val txData = uncompiled(
|
||||
destinationAddress = "",
|
||||
callData = TransferERC20TokenCallData(destination = "", amount = amount),
|
||||
)
|
||||
|
||||
// Act & Assert
|
||||
assertThrows<IllegalArgumentException> {
|
||||
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN zero address recipient WHEN validateTransactionRecipients THEN throws`() {
|
||||
// Arrange
|
||||
val txData = uncompiled(
|
||||
destinationAddress = zeroAddress,
|
||||
callData = TransferERC20TokenCallData(destination = zeroAddress, amount = amount),
|
||||
)
|
||||
|
||||
// Act & Assert
|
||||
assertThrows<IllegalArgumentException> {
|
||||
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN zero address inside call data only WHEN validateTransactionRecipients THEN throws`() {
|
||||
// Arrange
|
||||
val txData = uncompiled(
|
||||
destinationAddress = tokenContract,
|
||||
callData = TransferERC20TokenCallData(destination = zeroAddress, amount = amount),
|
||||
)
|
||||
|
||||
// Act & Assert
|
||||
assertThrows<IllegalArgumentException> {
|
||||
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN dead address recipient WHEN validateTransactionRecipients THEN throws`() {
|
||||
// Arrange — the dead address is well-formed and non-zero, so every other check accepts it
|
||||
val txData = uncompiled(
|
||||
destinationAddress = deadAddress,
|
||||
callData = TransferERC20TokenCallData(destination = deadAddress, amount = amount),
|
||||
)
|
||||
|
||||
// Act & Assert
|
||||
assertThrows<IllegalArgumentException> {
|
||||
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN dead address inside yield supply send call data WHEN validateTransactionRecipients THEN throws`() {
|
||||
// Arrange — for a yield-supply send the recipient lives in the call data
|
||||
val txData = uncompiled(
|
||||
destinationAddress = yieldModule,
|
||||
callData = EthereumYieldSupplySendCallData(
|
||||
tokenContractAddress = tokenContract,
|
||||
destinationAddress = deadAddress,
|
||||
amount = amount,
|
||||
),
|
||||
)
|
||||
|
||||
// Act & Assert
|
||||
assertThrows<IllegalArgumentException> {
|
||||
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid yield supply send WHEN validateTransactionRecipients THEN passes`() {
|
||||
// Arrange
|
||||
val txData = uncompiled(
|
||||
destinationAddress = yieldModule,
|
||||
callData = EthereumYieldSupplySendCallData(
|
||||
tokenContractAddress = tokenContract,
|
||||
destinationAddress = recipient,
|
||||
amount = amount,
|
||||
),
|
||||
)
|
||||
|
||||
// Act & Assert — no exception
|
||||
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN missing call data WHEN validateTransactionRecipients THEN throws`() {
|
||||
// Arrange
|
||||
val txData = uncompiled(destinationAddress = recipient, callData = null)
|
||||
|
||||
// Act & Assert
|
||||
assertThrows<IllegalStateException> {
|
||||
CreateAndSendGaslessTransactionUseCase.validateTransactionRecipients(blockchain, txData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -155,6 +155,11 @@ private fun LazyListScope.addressItem(
|
|||
onAddressChange(it, EnterAddressSource.PasteButton)
|
||||
}
|
||||
},
|
||||
onClearClick = {
|
||||
GlobalMultipleClickPreventer.processEvent {
|
||||
onAddressChange("", EnterAddressSource.InputField)
|
||||
}
|
||||
},
|
||||
onQrCodeClick = onQrCodeClick,
|
||||
isError = isError,
|
||||
isLoading = isValidating,
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ internal fun TextFieldWithPaste(
|
|||
.align(CenterVertically),
|
||||
) {
|
||||
CrossIcon(
|
||||
onClick = onPasteClick,
|
||||
onClick = { onPasteClick("") },
|
||||
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.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<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/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 ^
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue