Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-15 12:24:18 +03:00
parent de838113af
commit e26c124d0f
2 changed files with 103 additions and 11 deletions

View file

@ -200,25 +200,24 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
verifyContext: Wallet.Model.VerifyContext,
): Either<WcPairError, WcPairState.Proposal> = 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.
// Display URL: shown to the user and logged to analytics. getDappOriginUrl() returns the
// Verify-attested origin (verifyContext.origin), or the verify.walletconnect.org fallback
// when origin is empty. Display only — the security verdict is decided below (see
// isDomainConfirmed), where sessionProposal.url is used solely for a host-equality check.
val displayUrl = verifyContext.getDappOriginUrl()
val verificationInfo = when {
verifyContext.validation == Wallet.Model.Validation.INVALID -> CheckDAppResult.UNSAFE
verifyContext.isScam == true -> CheckDAppResult.UNSAFE
// 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 -> {
// BlockAid scans only the Verify-attested origin (verifyContext.origin), reached for
// VALID or for a false-positive INVALID whose metadata host matches that origin (see
// isDomainConfirmed). For UNKNOWN there is no trustworthy origin, so BlockAid is
// skipped to avoid letting an impersonator inherit its target's verdict.
isDomainConfirmed(verifyContext, sessionProposal.url) -> {
blockAidVerifier.verifyDApp(DAppData(verifyContext.origin)).getOrElse { error ->
TangemLogger.withTag(WC_TAG).e("Failed to verify DApp ${sessionProposal.name}", error)
CheckDAppResult.FAILED_TO_VERIFY
}
}
verifyContext.validation == Wallet.Model.Validation.INVALID -> CheckDAppResult.UNSAFE
else -> CheckDAppResult.FAILED_TO_VERIFY
}
val requestedNetworks = proposalAccountNetwork
@ -254,8 +253,31 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
},
)
private fun isDomainConfirmed(verifyContext: Wallet.Model.VerifyContext, metadataUrl: String): Boolean {
return when (verifyContext.validation) {
Wallet.Model.Validation.VALID -> true
Wallet.Model.Validation.INVALID -> hostsMatchWithScheme(metadataUrl, verifyContext.origin)
else -> false
}
}
private fun hostsMatchWithScheme(metadataUrl: String, origin: String): Boolean = runCatching {
val metadataHost = URI(metadataUrl.ensureScheme()).host?.lowercase()
val originHost = URI(origin.ensureScheme()).host?.lowercase()
!metadataHost.isNullOrEmpty() && metadataHost == originHost
}.getOrDefault(false)
private fun String.ensureScheme(): String =
if (startsWith(HTTP_SCHEME, ignoreCase = true) || startsWith(HTTPS_SCHEME, ignoreCase = true)) {
this
} else {
HTTPS_SCHEME + this
}
private companion object {
const val PENDING_SESSION_EXPIRED_DURATION_MIN = 15L
const val HTTP_SCHEME = "http://"
const val HTTPS_SCHEME = "https://"
}
private sealed interface TerminalAction {

View file

@ -23,9 +23,11 @@ import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.model.WcSessionApprove
import com.tangem.domain.walletconnect.usecase.pair.WcPairState
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertInstanceOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -287,4 +289,72 @@ internal class DefaultWcPairUseCaseTest {
awaitComplete()
}
}
@Test
fun `pair treats invalid validation with scheme-less metadata url matching origin as verified`() = runTest {
val schemelessProposal = sdkProposal.copy(url = "app.eigenlayer.xyz")
val invalidVerifyContext = sdkVerifyContext.copy(
origin = "https://app.eigenlayer.xyz",
validation = Wallet.Model.Validation.INVALID,
)
coEvery { sdkDelegate.pair(url) } returns (schemelessProposal to invalidVerifyContext).right()
coEvery { associateNetworksDelegate.associateAccounts(schemelessProposal) } 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(invalidVerifyContext.origin))
}
val proposal = assertInstanceOf(WcPairState.Proposal::class.java, awaitItem())
assertEquals(CheckDAppResult.SAFE, proposal.dAppSession.securityStatus)
expectNoEvents()
}
}
@Test
fun `pair treats invalid validation with case-differing scheme-less metadata url as verified`() = runTest {
val schemelessProposal = sdkProposal.copy(url = "APP.EigenLayer.xyz")
val invalidVerifyContext = sdkVerifyContext.copy(
origin = "HTTPS://app.eigenlayer.xyz",
validation = Wallet.Model.Validation.INVALID,
)
coEvery { sdkDelegate.pair(url) } returns (schemelessProposal to invalidVerifyContext).right()
coEvery { associateNetworksDelegate.associateAccounts(schemelessProposal) } 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(invalidVerifyContext.origin))
}
val proposal = assertInstanceOf(WcPairState.Proposal::class.java, awaitItem())
assertEquals(CheckDAppResult.SAFE, proposal.dAppSession.securityStatus)
expectNoEvents()
}
}
@Test
fun `pair keeps genuine invalid domain unsafe and skips blockaid`() = runTest {
val proposal = sdkProposal.copy(url = "https://legit-dapp.example/")
val invalidVerifyContext = sdkVerifyContext.copy(
origin = "https://phishing.example/",
validation = Wallet.Model.Validation.INVALID,
)
coEvery { sdkDelegate.pair(url) } returns (proposal to invalidVerifyContext).right()
coEvery { associateNetworksDelegate.associateAccounts(proposal) } returns mapOf()
val useCase = useCaseFactory()
useCase.invoke().test {
assertEquals(loading, awaitItem())
val state = assertInstanceOf(WcPairState.Proposal::class.java, awaitItem())
assertEquals(CheckDAppResult.UNSAFE, state.dAppSession.securityStatus)
expectNoEvents()
}
coVerify(exactly = 0) { blockAidVerifier.verifyDApp(any()) }
}
}