From f1a4be49b3cc3ecd30f07278c4a604fd82f52242 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 14 May 2026 11:48:39 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../tangem/common/constants/TestConstants.kt | 1 + .../common/extensions/CustomAssertsExt.kt | 12 ++ .../com/tangem/common/utils/NetworkUtils.kt | 12 +- .../scenarios/WalletConnectScenarios.kt | 95 ++++++++++-- .../screens/WarningBottomSheetPageObject.kt | 20 ++- .../tests/send/sendViaSwap/SendViaSwapTest.kt | 2 +- .../EthereumWalletConnectTest.kt | 51 ++----- .../walletConnect/SolanaWalletConnectTest.kt | 51 ++----- .../tests/walletConnect/WalletConnectTest.kt | 143 ++++++++++++++++++ .../pair/DefaultWcPairUseCase.kt | 23 ++- .../walletconnect/pair/WcPairSdkDelegate.kt | 8 +- .../walletconnect/DefaultWcPairUseCaseTest.kt | 19 +++ .../connections/model/WcPairModel.kt | 3 +- 13 files changed, 337 insertions(+), 103 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/walletConnect/WalletConnectTest.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt index 0974d1c8fb..4e6cfb4444 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -34,6 +34,7 @@ object TestConstants { const val TERRA_RECIPIENT_ADDRESS = "terra148dmp5ccazcwdmrcpvqz5rprnn886kemqen3tj" const val POLYGON_RECIPIENT_ADDRESS = "0x742d35cc6634c0532925a3b844bc9e7595f2bd18" + const val WAIT_UNTIL_TIMEOUT_SHORT = 5_000L const val WAIT_UNTIL_TIMEOUT = 20_000L const val WAIT_UNTIL_TIMEOUT_LONG = 30_000L const val WAIT_UNTIL_TIMEOUT_VERY_LONG = 60_000L diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt index 079d2580b5..87db4a23e4 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt @@ -3,6 +3,9 @@ package com.tangem.common.extensions import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.onAllNodesWithText +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.utils.LazyListItemNode import com.tangem.core.ui.components.buttons.actions.HasBadgeKey import com.tangem.core.ui.components.buttons.actions.IsDimmedKey @@ -10,6 +13,15 @@ import io.github.kakaocup.compose.node.element.KNode import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +fun BaseTestCase.assertSnackbarWithText(text: String, timeoutMs: Long = WAIT_UNTIL_TIMEOUT) { + composeTestRule.waitUntil(timeoutMillis = timeoutMs) { + composeTestRule + .onAllNodesWithText(text, substring = true) + .fetchSemanticsNodes() + .isNotEmpty() + } +} + fun assertElementDoesNotExist( elementProvider: () -> KNode, elementDescription: String, diff --git a/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt b/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt index c8d2dfe099..675839e656 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt @@ -1,5 +1,6 @@ package com.tangem.common.utils +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONObject @@ -21,9 +22,18 @@ private fun redactWcSecrets(text: String): String = */ fun getWcUri( network: String = "ethereum", + dAppUrl: String? = null, + dAppName: String? = null, baseUrl: String = "[REDACTED_ENV_URL]" ): String? { - val url = "$baseUrl/wc_uri?network=$network" + val url = "$baseUrl/wc_uri".toHttpUrl().newBuilder() + .addQueryParameter("network", network) + .apply { + if (dAppUrl != null) addQueryParameter("dappUrl", dAppUrl) + if (dAppName != null) addQueryParameter("dappName", dAppName) + } + .build() + .toString() TangemLogger.i("getWcUri: requesting $url") val client = OkHttpClient.Builder() diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt index ddb18238d0..616396504c 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/WalletConnectScenarios.kt @@ -1,9 +1,19 @@ package com.tangem.scenarios +import android.content.Context +import androidx.compose.ui.test.onAllNodesWithText import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_SHORT +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.setClipboardText +import com.tangem.core.ui.R +import com.tangem.screens.onScanQrScreen import com.tangem.screens.onWalletConnectBottomSheet import com.tangem.screens.onWalletConnectDetailsBottomSheet import com.tangem.screens.onWalletConnectScreen +import com.tangem.screens.onWarningBottomSheet +import io.github.kakaocup.kakao.common.utilities.getResourceString import io.qameta.allure.kotlin.Allure.step fun BaseTestCase.checkWalletConnectBottomSheet() { @@ -17,9 +27,6 @@ fun BaseTestCase.checkWalletConnectBottomSheet() { step("Assert 'Wallet Connect' bottom sheet app name is displayed") { onWalletConnectBottomSheet { appName.assertIsDisplayed() } } - step("Assert 'Wallet Connect' bottom sheet approve icon is displayed") { - onWalletConnectBottomSheet { approveIcon.assertIsDisplayed() } - } step("Assert 'Wallet Connect' bottom sheet app URL is displayed") { onWalletConnectBottomSheet { appUrl.assertIsDisplayed() } } @@ -82,9 +89,6 @@ fun BaseTestCase.checkWalletConnectScreen(withConnections: Boolean) { step("Assert app name is displayed") { onWalletConnectScreen { appName.assertIsDisplayed() } } - step("Assert approve icon is displayed") { - onWalletConnectScreen { approveIcon.assertIsDisplayed() } - } step("Assert app URL is displayed") { onWalletConnectScreen { appUrl.assertIsDisplayed() } } @@ -117,6 +121,73 @@ fun BaseTestCase.checkWalletConnectScreen(withConnections: Boolean) { } +fun BaseTestCase.establishAndDisconnectWcSession( + context: Context, + deepLinkUri: String?, + dAppName: String, +) { + step("Set URI to clipboard") { + setClipboardText(context, deepLinkUri) + } + step("Create connection via 'Paste from clipboard' button") { + createConnectionViaPasteFromClipboardButton() + } + step("Check 'Wallet Connect' bottom sheet") { + composeTestRule.waitUntil(timeoutMillis = TestConstants.WAIT_UNTIL_TIMEOUT) { + runCatching { checkWalletConnectBottomSheet() }.isSuccess + } + } + step("Click on 'Connect' button and dismiss 'Unknown domain' alert if shown") { + confirmWcConnection() + } + step("Check 'Wallet Connect' screen with connections") { + checkWalletConnectScreen(withConnections = true) + } + step("Click on app icon") { + onWalletConnectScreen { appIcon.performClick() } + } + step("Check 'Wallet Connect' details bottom sheet") { + checkWalletConnectDetailsBottomSheet(dAppName) + } + step("Click on 'Disconnect' button") { + onWalletConnectDetailsBottomSheet { disconnectButton.performClick() } + } +} + +/** + * Clicks 'Connect' in the WalletConnect bottom sheet and dismisses the 'Unknown domain' security + * alert if it appears. + * + * qa-tools URIs are not registered with Reown Verify API, so Reown returns validation=UNKNOWN — + * after the production change in DefaultWcPairUseCase that maps UNKNOWN to FAILED_TO_VERIFY, the + * app shows a Security Alert before establishing the session. Tests that drive qa-tools URIs go + * through this helper to consistently accept the warning. + */ +fun BaseTestCase.confirmWcConnection() { + step("Click on 'Connect' button") { + onWalletConnectBottomSheet { connectButton.performClick() } + } + waitForIdle() + + val alertText = getResourceString(R.string.wc_alert_connect_anyway) + val alertAppeared = runCatching { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_SHORT) { + composeTestRule.onAllNodesWithText(alertText).fetchSemanticsNodes().isNotEmpty() + } + }.isSuccess + + if (alertAppeared) { + step("Click on 'Connect anyway' button") { + onWarningBottomSheet { connectAnywayButton.clickWithAssertion() } + } + } + + step("Assert 'Connect' button is not displayed") { + waitForIdle() + onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } + } +} + fun BaseTestCase.checkWalletConnectDetailsBottomSheet(dAppName: String) { waitForIdle() step("Assert connection details title is displayed") { @@ -134,9 +205,6 @@ fun BaseTestCase.checkWalletConnectDetailsBottomSheet(dAppName: String) { step("Assert app name is displayed") { onWalletConnectDetailsBottomSheet { appName.assertIsDisplayed() } } - step("Assert approve icon is displayed") { - onWalletConnectDetailsBottomSheet { approveIcon.assertIsDisplayed() } - } step("Assert app URL is displayed") { onWalletConnectDetailsBottomSheet { appUrl.assertIsDisplayed() } } @@ -158,4 +226,13 @@ fun BaseTestCase.checkWalletConnectDetailsBottomSheet(dAppName: String) { step("Assert 'Disconnect button' is displayed") { onWalletConnectDetailsBottomSheet { disconnectButton.assertIsDisplayed() } } +} + +fun BaseTestCase.createConnectionViaPasteFromClipboardButton() { + step("Click on 'New connection' button") { + onWalletConnectScreen { newConnectionButton.performClick() } + } + step("Click on 'Paste from clipboard' button") { + onScanQrScreen { pasteFromClipboardButton.clickWithAssertion() } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WarningBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WarningBottomSheetPageObject.kt index a0a7cf8c2a..56bb30983e 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WarningBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/WarningBottomSheetPageObject.kt @@ -30,11 +30,29 @@ class WarningBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsP useUnmergedTree = true } - val gotItButton: KNode = child { + val okGotItButton: KNode = child { hasTestTag(BaseButtonTestTags.TEXT) hasText(getResourceString(R.string.warning_button_ok)) useUnmergedTree = true } + + val gotItButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_got_it)) + useUnmergedTree = true + } + + val cancelButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_cancel)) + useUnmergedTree = true + } + + val connectAnywayButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.wc_alert_connect_anyway)) + useUnmergedTree = true + } } internal fun BaseTestCase.onWarningBottomSheet(function: WarningBottomSheetPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt index 81e533d007..924bb8603b 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt @@ -75,7 +75,7 @@ class SendViaSwapTest : BaseTestCase() { onWarningBottomSheet { message(warningMessage).assertIsDisplayed() } } step("Click on 'Ok, Got it!' button") { - onWarningBottomSheet { gotItButton.performClick() } + onWarningBottomSheet { okGotItButton.performClick() } } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/EthereumWalletConnectTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/EthereumWalletConnectTest.kt index fe0e4e40af..8f9f7f3a1e 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/EthereumWalletConnectTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/EthereumWalletConnectTest.kt @@ -3,17 +3,9 @@ package com.tangem.tests.walletConnect import android.Manifest import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants -import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.getWcUri import com.tangem.common.utils.setClipboardText -import com.tangem.scenarios.checkWalletConnectBottomSheet -import com.tangem.scenarios.checkWalletConnectDetailsBottomSheet -import com.tangem.scenarios.checkWalletConnectScreen -import com.tangem.scenarios.openAppByDeepLink -import com.tangem.scenarios.openMainScreen -import com.tangem.scenarios.openWalletConnectScreen -import com.tangem.scenarios.synchronizeAddresses -import com.tangem.screens.onScanQrScreen +import com.tangem.scenarios.* import com.tangem.screens.onWalletConnectBottomSheet import com.tangem.screens.onWalletConnectDetailsBottomSheet import com.tangem.screens.onWalletConnectScreen @@ -51,13 +43,8 @@ class EthereumWalletConnectTest : BaseTestCase() { step("Assert 'Connect' button is enabled") { onWalletConnectBottomSheet { connectButton.assertIsEnabled() } } - step("Click on 'Connect' button") { - waitForIdle() - onWalletConnectBottomSheet { connectButton.performClick() } - } - step("Assert 'Connect' button is not displayed") { - waitForIdle() - onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } + step("Click on 'Connect' button and dismiss 'Unknown domain' alert if shown") { + confirmWcConnection() } step("Open 'Wallet Connect' screen") { openWalletConnectScreen() @@ -108,13 +95,8 @@ class EthereumWalletConnectTest : BaseTestCase() { checkWalletConnectBottomSheet() } } - step("Click on 'Connect' button") { - waitForIdle() - onWalletConnectBottomSheet { connectButton.performClick() } - } - step("Assert 'Connect' button is not displayed") { - waitForIdle() - onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } + step("Click on 'Connect' button and dismiss 'Unknown domain' alert if shown") { + confirmWcConnection() } step("Check 'Wallet Connect' screen with connections") { flakySafely(TestConstants.WAIT_UNTIL_TIMEOUT) { @@ -164,11 +146,8 @@ class EthereumWalletConnectTest : BaseTestCase() { checkWalletConnectBottomSheet() } } - step("Click on 'Connect' button") { - onWalletConnectBottomSheet { connectButton.performClick() } - } - step("Assert 'Connect' button is not displayed") { - onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } + step("Click on 'Connect' button and dismiss 'Unknown domain' alert if shown") { + confirmWcConnection() } step("Open 'Wallet Connect' screen") { openWalletConnectScreen() @@ -218,11 +197,8 @@ class EthereumWalletConnectTest : BaseTestCase() { step("Open 'Wallet Connect' screen") { openWalletConnectScreen() } - step("Click 'New connection' button") { - onWalletConnectScreen { newConnectionButton.performClick() } - } - step("CLick 'Paste from clipboard' button") { - onScanQrScreen { pasteFromClipboardButton.clickWithAssertion() } + step("Create connection via 'Paste from clipboard' button") { + createConnectionViaPasteFromClipboardButton() } step("Check 'Wallet Connect' bottom sheet") { waitForIdle() @@ -230,13 +206,8 @@ class EthereumWalletConnectTest : BaseTestCase() { checkWalletConnectBottomSheet() } } - step("Click on 'Connect' button") { - waitForIdle() - onWalletConnectBottomSheet { connectButton.performClick() } - } - step("Assert 'Connect' button is not displayed") { - waitForIdle() - onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } + step("Click on 'Connect' button and dismiss 'Unknown domain' alert if shown") { + confirmWcConnection() } step("Check 'Wallet Connect' screen with connections") { checkWalletConnectScreen(withConnections = true) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/SolanaWalletConnectTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/SolanaWalletConnectTest.kt index facd7d0fad..682a555487 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/SolanaWalletConnectTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/SolanaWalletConnectTest.kt @@ -4,19 +4,11 @@ import android.Manifest import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO -import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.getWcUri import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setClipboardText import com.tangem.common.utils.setWireMockScenarioState -import com.tangem.scenarios.checkWalletConnectBottomSheet -import com.tangem.scenarios.checkWalletConnectDetailsBottomSheet -import com.tangem.scenarios.checkWalletConnectScreen -import com.tangem.scenarios.openAppByDeepLink -import com.tangem.scenarios.openMainScreen -import com.tangem.scenarios.openWalletConnectScreen -import com.tangem.scenarios.synchronizeAddresses -import com.tangem.screens.onScanQrScreen +import com.tangem.scenarios.* import com.tangem.screens.onWalletConnectBottomSheet import com.tangem.screens.onWalletConnectDetailsBottomSheet import com.tangem.screens.onWalletConnectScreen @@ -64,13 +56,8 @@ class SolanaWalletConnectTest : BaseTestCase() { step("Assert 'Connect' button is enabled") { onWalletConnectBottomSheet { connectButton.assertIsEnabled() } } - step("Click on 'Connect' button") { - waitForIdle() - onWalletConnectBottomSheet { connectButton.performClick() } - } - step("Assert 'Connect' button is not displayed") { - waitForIdle() - onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } + step("Click on 'Connect' button and dismiss 'Unknown domain' alert if shown") { + confirmWcConnection() } step("Open 'Wallet Connect' screen") { openWalletConnectScreen() @@ -131,13 +118,8 @@ class SolanaWalletConnectTest : BaseTestCase() { checkWalletConnectBottomSheet() } } - step("Click on 'Connect' button") { - waitForIdle() - onWalletConnectBottomSheet { connectButton.performClick() } - } - step("Assert 'Connect' button is not displayed") { - waitForIdle() - onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } + step("Click on 'Connect' button and dismiss 'Unknown domain' alert if shown") { + confirmWcConnection() } step("Check 'Wallet Connect' screen with connections") { flakySafely(TestConstants.WAIT_UNTIL_TIMEOUT) { @@ -197,11 +179,8 @@ class SolanaWalletConnectTest : BaseTestCase() { checkWalletConnectBottomSheet() } } - step("Click on 'Connect' button") { - onWalletConnectBottomSheet { connectButton.performClick() } - } - step("Assert 'Connect' button is not displayed") { - onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } + step("Click on 'Connect' button and dismiss 'Unknown domain' alert if shown") { + confirmWcConnection() } step("Open 'Wallet Connect' screen") { openWalletConnectScreen() @@ -261,11 +240,8 @@ class SolanaWalletConnectTest : BaseTestCase() { step("Open 'Wallet Connect' screen") { openWalletConnectScreen() } - step("Click 'New connection' button") { - onWalletConnectScreen { newConnectionButton.performClick() } - } - step("CLick 'Paste from clipboard' button") { - onScanQrScreen { pasteFromClipboardButton.clickWithAssertion() } + step("Create connection via 'Paste from clipboard' button") { + createConnectionViaPasteFromClipboardButton() } step("Check 'Wallet Connect' bottom sheet") { waitForIdle() @@ -273,13 +249,8 @@ class SolanaWalletConnectTest : BaseTestCase() { checkWalletConnectBottomSheet() } } - step("Click on 'Connect' button") { - waitForIdle() - onWalletConnectBottomSheet { connectButton.performClick() } - } - step("Assert 'Connect' button is not displayed") { - waitForIdle() - onWalletConnectBottomSheet { connectButton.assertIsNotDisplayed() } + step("Click on 'Connect' button and dismiss 'Unknown domain' alert if shown") { + confirmWcConnection() } step("Check 'Wallet Connect' screen with connections") { checkWalletConnectScreen(withConnections = true) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/WalletConnectTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/WalletConnectTest.kt new file mode 100644 index 0000000000..c1378a2510 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/walletConnect/WalletConnectTest.kt @@ -0,0 +1,143 @@ +package com.tangem.tests.walletConnect + +import android.Manifest +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants +import com.tangem.common.extensions.assertSnackbarWithText +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.getWcUri +import com.tangem.common.utils.setClipboardText +import com.tangem.scenarios.* +import com.tangem.screens.onWarningBottomSheet +import com.tangem.wallet.BuildConfig +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class WalletConnectTest : BaseTestCase() { + + @AllureId("9037") + @DisplayName("WC: invalid wallet connect link") + @Test + fun invalidWalletConnectLinkTest() { + val context = device.context + val deepLinkUri = "wc:384617d590a47f11c26311b5cf2418859682920aa0ad52" + val packageName = BuildConfig.APPLICATION_ID + val permissionName = Manifest.permission.CAMERA + + setupHooks( + additionalBeforeSection = { + device.uiDevice.executeShellCommand("pm grant $packageName $permissionName") + }, + ).run { + step("Set URI to clipboard") { + setClipboardText(context, deepLinkUri) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() + } + step("Create connection via 'Paste from clipboard' button") { + createConnectionViaPasteFromClipboardButton() + } + step("Assert error snackbar about invalid WC URI is displayed") { + assertSnackbarWithText("getUserInfo") + } + } + } + + @AllureId("9040") + @DisplayName("WC (React App): repeat open/close session") + @Test + fun repeatedConnectByWalletConnectDeeplinkScreenTest() { + val dAppName = "Tangem QA Tools" + val context = device.context + val packageName = BuildConfig.APPLICATION_ID + val permissionName = Manifest.permission.CAMERA + val sessionsCount = 3 + + setupHooks( + additionalBeforeSection = { + device.uiDevice.executeShellCommand("pm grant $packageName $permissionName") + }, + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() + } + repeat(sessionsCount) { iteration -> + step("Session #${iteration + 1}: connect and disconnect") { + establishAndDisconnectWcSession( + context = context, + deepLinkUri = getWcUri(), + dAppName = dAppName, + ) + } + step("Check 'Wallet Connect' screen without connections") { + checkWalletConnectScreen(withConnections = false) + } + } + } + } + + @AllureId("9066") + @DisplayName("WC: connect to unsupported dApp shows error") + @Test + fun connectToUnsupportedDAppShowsUnsupportedErrorTest() { + val unsupportedDAppUrl = "https://dydx.trade/test" + val dAppName = "dYdX" + val context = device.context + val packageName = BuildConfig.APPLICATION_ID + val permissionName = Manifest.permission.CAMERA + + setupHooks( + additionalBeforeSection = { + device.uiDevice.executeShellCommand("pm grant $packageName $permissionName") + }, + ).run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Wallet Connect' screen") { + openWalletConnectScreen() + } + step("Set unsupported dApp URI to clipboard") { + setClipboardText( + context = context, + text = getWcUri(dAppUrl = unsupportedDAppUrl, dAppName = dAppName), + ) + } + step("Create connection via 'Paste from clipboard' button") { + createConnectionViaPasteFromClipboardButton() + } + step("Wait for unsupported dApp error bottom sheet") { + composeTestRule.waitUntil(timeoutMillis = TestConstants.WAIT_UNTIL_TIMEOUT) { + runCatching { + onWarningBottomSheet { gotItButton.assertIsDisplayed() } + }.isSuccess + } + } + step("Click on 'Got it' button") { + onWarningBottomSheet { gotItButton.clickWithAssertion() } + } + step("Check 'Wallet Connect' screen without connections") { + checkWalletConnectScreen(withConnections = false) + } + } + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt index c7e868cf94..1e0de1fd63 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt @@ -200,20 +200,33 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( verifyContext: Wallet.Model.VerifyContext, ): Either = runCatching { val proposalAccountNetwork = associateNetworksDelegate.associateAccounts(sessionProposal) + // Display URL: shown to the user and logged to analytics. Reown's verified origin when + // present, otherwise its `verify.walletconnect.org` fallback. NOT trustworthy for + // security checks: when validation is INVALID, getDappOriginUrl returns the dApp-claimed + // origin (so the UI can show what was claimed), which a scam dApp can spoof. + val displayUrl = verifyContext.getDappOriginUrl() val verificationInfo = when { verifyContext.validation == Wallet.Model.Validation.INVALID -> CheckDAppResult.UNSAFE verifyContext.isScam == true -> CheckDAppResult.UNSAFE - else -> blockAidVerifier.verifyDApp(DAppData(sessionProposal.url)).getOrElse { error -> - TangemLogger.withTag(WC_TAG).e("Failed to verify DApp ${sessionProposal.name}", error) - CheckDAppResult.FAILED_TO_VERIFY + // BlockAid is scanned only against the Reown-verified origin (validation == VALID + // guarantees Reown confirmed origin matches the dApp's registered domain). + // For UNKNOWN we have no trustworthy URL: passing a dApp-claimed URL would let an + // impersonator (e.g. a scam claiming metadata.url=dydx.trade) inherit its target's + // BlockAid verdict. + verifyContext.validation == Wallet.Model.Validation.VALID -> { + blockAidVerifier.verifyDApp(DAppData(verifyContext.origin)).getOrElse { error -> + TangemLogger.withTag(WC_TAG).e("Failed to verify DApp ${sessionProposal.name}", error) + CheckDAppResult.FAILED_TO_VERIFY + } } + else -> CheckDAppResult.FAILED_TO_VERIFY } val requestedNetworks = proposalAccountNetwork .values.map { it.available.plus(it.required) }.flatten().toSet() analytics.send( WcAnalyticEvents.PairRequested( dAppName = sessionProposal.name, - dAppUrl = sessionProposal.url, + dAppUrl = displayUrl, network = requestedNetworks, domainVerification = verificationInfo, ), @@ -221,7 +234,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( val appMetaData = WcAppMetaData( name = sessionProposal.name, description = sessionProposal.description, - url = sessionProposal.url, + url = displayUrl, icons = sessionProposal.icons.map { it.toString() }, redirect = sessionProposal.redirect, ) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt index d5aa2c0090..f38a22770e 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt @@ -7,7 +7,6 @@ import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import com.tangem.domain.walletconnect.WC_TAG import com.tangem.data.walletconnect.utils.WcSdkObserver -import com.tangem.data.walletconnect.utils.getDappOriginUrl import com.tangem.datasource.local.walletconnect.WalletConnectStore import com.tangem.domain.walletconnect.model.WcPairError import com.tangem.domain.walletconnect.model.WcPairError.ApprovalFailed @@ -110,9 +109,10 @@ internal class WcPairSdkDelegate( sessionProposal: Wallet.Model.SessionProposal, verifyContext: Wallet.Model.VerifyContext, ) { - val sessionProposalWithRealUrl = sessionProposal.copy(url = verifyContext.getDappOriginUrl()) - // Triggered when wallet receives the session proposal sent by a Dapp - onSessionProposal.trySend(sessionProposalWithRealUrl to verifyContext) + // Triggered when wallet receives the session proposal sent by a Dapp. + // Pass the proposal through unchanged so consumers can decide between the dApp-claimed + // metadata url (sessionProposal.url) and the Verify-API origin (verifyContext.getDappOriginUrl()). + onSessionProposal.trySend(sessionProposal to verifyContext) } override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) { diff --git a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt index afaed251c9..1c53825de4 100644 --- a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt +++ b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt @@ -147,6 +147,25 @@ internal class DefaultWcPairUseCaseTest { } } + @Test + fun `verifyDApp uses verifyContext origin when sessionProposal url is spoofed`() = runTest { + val spoofedProposal = sdkProposal.copy(url = "https://evil-spoofed.example/") + coEvery { sdkDelegate.pair(url) } returns (spoofedProposal to sdkVerifyContext).right() + coEvery { associateNetworksDelegate.associateAccounts(spoofedProposal) } returns mapOf() + coEvery { blockAidVerifier.verifyDApp(any()) } returns Either.catch { CheckDAppResult.SAFE } + + val useCase = useCaseFactory() + useCase.invoke().test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + blockAidVerifier.verifyDApp(DAppData(sdkVerifyContext.origin)) + } + assert(awaitItem() is WcPairState.Proposal) + expectNoEvents() + } + } + @Test fun `success pair and approve flow`() = runTest { val approveLoading = WcPairState.Approving.Loading(sessionForApprove) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt index c6c3e4f486..b60fb5c790 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt @@ -20,7 +20,6 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.SnackbarMessage -import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus @@ -338,7 +337,7 @@ internal class WcPairModel @Inject constructor( is WcPairError.UriAlreadyUsed -> WcAppInfoRoutes.Alert.UriAlreadyUsed is WcPairError.TimeoutException -> WcAppInfoRoutes.Alert.TimeoutException else -> { - messageSender.send(ToastMessage(message = stringReference(error.message))) + messageSender.send(SnackbarMessage(message = stringReference(error.message))) router.pop() null }