Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 16:01:17 +01:00
parent 5ac7254d82
commit e795c935aa
15 changed files with 270 additions and 242 deletions

View file

@ -4,7 +4,7 @@ import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase
import com.tangem.domain.addressbook.usecase.VerifyAddressEntriesUseCase import com.tangem.domain.addressbook.usecase.VerifyAddressEntriesUseCase
import com.tangem.domain.tokens.GetNetworkAddressesUseCase import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.transaction.usecase.VerifyMessagesUseCase import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -29,7 +29,9 @@ object AddressBookDomainModule {
@Provides @Provides
@Singleton @Singleton
fun provideVerifyAddressEntriesUseCase(verifyMessagesUseCase: VerifyMessagesUseCase): VerifyAddressEntriesUseCase { fun provideVerifyAddressEntriesUseCase(
verifyMessagesUseCase: VerifySecp256k1MessagesUseCase,
): VerifyAddressEntriesUseCase {
return VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase) return VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase)
} }
} }

View file

@ -237,20 +237,8 @@ internal object TransactionDomainModule {
@Provides @Provides
@Singleton @Singleton
fun provideSignHashesUseCase( fun provideVerifySecp256k1MessagesUseCase(): VerifySecp256k1MessagesUseCase {
cardSdkConfigRepository: CardSdkConfigRepository, return VerifySecp256k1MessagesUseCase()
tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory,
): SignHashesUseCase {
return SignHashesUseCase(
cardSdkConfigRepository = cardSdkConfigRepository,
getHotTransactionSigner = { tangemHotWalletSignerFactory.create(it) },
)
}
@Provides
@Singleton
fun provideVerifyMessagesUseCase(): VerifyMessagesUseCase {
return VerifyMessagesUseCase()
} }
@Provides @Provides

View file

@ -83,7 +83,13 @@ internal class WcSolanaMessageSignUseCaseTest {
assertTrue(result.isLeft()) assertTrue(result.isLeft())
assertTrue(result.leftOrNull() is WcRequestError.UnknownError) assertTrue(result.leftOrNull() is WcRequestError.UnknownError)
} }
coVerify(exactly = 0) { signUseCase(any(), any(), any()) } coVerify(exactly = 0) {
signUseCase(
hash = any(),
userWallet = any(),
network = any(),
)
}
coVerify(exactly = 0) { respondService.respond(any(), any()) } coVerify(exactly = 0) { respondService.respond(any(), any()) }
} }
@ -92,7 +98,13 @@ internal class WcSolanaMessageSignUseCaseTest {
runTest(UnconfinedTestDispatcher()) { runTest(UnconfinedTestDispatcher()) {
// Arrange // Arrange
val message = "Sign in to Tangem\nNonce: 8f3a91c0d4".toByteArray() val message = "Sign in to Tangem\nNonce: 8f3a91c0d4".toByteArray()
coEvery { signUseCase(any(), any(), any()) } returns byteArrayOf(0x0A, 0x0B, 0x0C).right() coEvery {
signUseCase(
hash = any(),
userWallet = any(),
network = any(),
)
} returns byteArrayOf(0x0A, 0x0B, 0x0C).right()
coEvery { respondService.respond(any(), any()) } returns RESPOND_RESULT.right() coEvery { respondService.respond(any(), any()) } returns RESPOND_RESULT.right()
val useCase = createUseCase(rawMessage = message.encodeBase58()) val useCase = createUseCase(rawMessage = message.encodeBase58())

View file

@ -23,5 +23,6 @@ dependencies {
// region Test libraries // region Test libraries
testImplementation(projects.test.core) testImplementation(projects.test.core)
testImplementation(projects.test.mock) testImplementation(projects.test.mock)
testImplementation(projects.common.test)
// endregion // endregion
} }

View file

@ -6,25 +6,27 @@ import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.SignHashesError import com.tangem.domain.transaction.error.SignHashesError
import com.tangem.domain.transaction.usecase.SignHashesUseCase import com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.domain.transaction.usecase.primarySecp256k1PublicKey
import com.tangem.utils.extensions.toHexString import com.tangem.utils.extensions.toHexString
import java.security.MessageDigest import java.security.MessageDigest
/** /**
* Signs every [AddressEntry] of a [Contact] with the wallet's primary key in a single signing * Signs every [AddressEntry] of a [Contact] with the wallet's primary secp256k1 key in a single
* session (one card tap). Each entry is hashed as `SHA-256(address + networkId + memo + contactId + * signing session (one card tap). Each entry is hashed as `SHA-256(address + networkId + memo +
* name)` and the produced signature is stored back into [AddressEntry.signature]. * contactId + name)` and the produced signature is stored back into [AddressEntry.signature].
*/ */
class SignAddressEntriesUseCase( class SignAddressEntriesUseCase(
private val signHashesUseCase: SignHashesUseCase, private val signUseCase: SignUseCase,
) { ) {
suspend operator fun invoke(userWallet: UserWallet, contact: Contact): Either<SignHashesError, Contact> = either { suspend operator fun invoke(userWallet: UserWallet, contact: Contact): Either<SignHashesError, Contact> = either {
val entries = contact.addressEntries val entries = contact.addressEntries
if (entries.isEmpty()) return@either contact if (entries.isEmpty()) return@either contact
val publicKey = userWallet.primarySecp256k1PublicKey() ?: raise(SignHashesError.NoSigningKey)
val hashes = entries.map { entry -> hashEntry(contact, entry) } val hashes = entries.map { entry -> hashEntry(contact, entry) }
val signatures = signHashesUseCase(userWallet = userWallet, hashes = hashes).bind() val signatures = signUseCase(hashes = hashes, publicKey = publicKey, userWallet = userWallet).bind()
val signedEntries = entries.mapIndexed { index, entry -> val signedEntries = entries.mapIndexed { index, entry ->
entry.copy(signature = signatures[index].toHexString()) entry.copy(signature = signatures[index].toHexString())

View file

@ -7,7 +7,7 @@ import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.VerifyMessagesError import com.tangem.domain.transaction.error.VerifyMessagesError
import com.tangem.domain.transaction.usecase.VerifyMessagesUseCase import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
import com.tangem.utils.extensions.hexToBytesOrNull import com.tangem.utils.extensions.hexToBytesOrNull
/** /**
@ -23,7 +23,7 @@ import com.tangem.utils.extensions.hexToBytesOrNull
* Each entry is verified against the exact bytes that were signed (see [buildAddressEntryPayload]). * Each entry is verified against the exact bytes that were signed (see [buildAddressEntryPayload]).
*/ */
class VerifyAddressEntriesUseCase( class VerifyAddressEntriesUseCase(
private val verifyMessagesUseCase: VerifyMessagesUseCase, private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase,
) { ) {
operator fun invoke( operator fun invoke(

View file

@ -3,6 +3,7 @@ package com.tangem.domain.addressbook.usecase
import arrow.core.left import arrow.core.left
import arrow.core.right import arrow.core.right
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.domain.addressbook.model.AddressEntry import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.Contact
@ -12,11 +13,12 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.SignHashesError import com.tangem.domain.transaction.error.SignHashesError
import com.tangem.domain.transaction.usecase.SignHashesUseCase import com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.utils.extensions.toHexString import com.tangem.utils.extensions.toHexString
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot import io.mockk.slot
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -28,14 +30,16 @@ import java.security.MessageDigest
@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SignAddressEntriesUseCaseTest { class SignAddressEntriesUseCaseTest {
private val signHashesUseCase: SignHashesUseCase = mockk() private val signUseCase: SignUseCase = mockk()
private val useCase = SignAddressEntriesUseCase(signHashesUseCase = signHashesUseCase) private val useCase = SignAddressEntriesUseCase(signUseCase = signUseCase)
private val userWallet: UserWallet = mockk() // The mock factory builds each wallet key with publicKey = curve.name bytes, so the secp256k1 key is "Secp256k1"
private val userWallet: UserWallet = MockUserWalletFactory.create()
private val secp256k1Key = "Secp256k1".toByteArray()
@BeforeEach @BeforeEach
fun resetMocks() { fun resetMocks() {
clearMocks(signHashesUseCase) clearMocks(signUseCase)
} }
@Test @Test
@ -47,7 +51,10 @@ class SignAddressEntriesUseCaseTest {
) )
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte())) val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte()))
val hashesSlot = slot<List<ByteArray>>() val hashesSlot = slot<List<ByteArray>>()
coEvery { signHashesUseCase(eq(userWallet), capture(hashesSlot)) } returns signatures.right() val publicKeySlot = slot<ByteArray>()
coEvery {
signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet))
} returns signatures.right()
// Act // Act
val result = useCase(userWallet, contact) val result = useCase(userWallet, contact)
@ -61,6 +68,8 @@ class SignAddressEntriesUseCaseTest {
), ),
) )
assertThat(result.getOrNull()).isEqualTo(expected) assertThat(result.getOrNull()).isEqualTo(expected)
// The wallet's primary secp256k1 key is the one signing
assertThat(publicKeySlot.captured).isEqualTo(secp256k1Key)
// Each entry is hashed as SHA-256(address + networkId + memo + contactId + name), in order // Each entry is hashed as SHA-256(address + networkId + memo + contactId + name), in order
assertThat(hashesSlot.captured.map { it.toHexString() }) assertThat(hashesSlot.captured.map { it.toHexString() })
.containsExactly( .containsExactly(
@ -80,20 +89,35 @@ class SignAddressEntriesUseCaseTest {
// Assert // Assert
assertThat(result.getOrNull()).isEqualTo(contact) assertThat(result.getOrNull()).isEqualTo(contact)
coVerify(exactly = 0) { signHashesUseCase(any(), any()) } coVerify(exactly = 0) { signUseCase(any<List<ByteArray>>(), any(), any()) }
} }
@Test @Test
fun `GIVEN signHashesUseCase returns error WHEN invoke THEN propagates the error`() = runTest { fun `GIVEN wallet without a secp256k1 key WHEN invoke THEN returns NoSigningKey without signing`() = runTest {
// Arrange — a locked hot wallet exposes no key
val lockedWallet = mockk<UserWallet.Hot> { every { wallets } returns null }
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null))
// Act
val result = useCase(lockedWallet, contact)
// Assert
assertThat(result.leftOrNull()).isEqualTo(SignHashesError.NoSigningKey)
coVerify(exactly = 0) { signUseCase(any<List<ByteArray>>(), any(), any()) }
}
@Test
fun `GIVEN signUseCase returns error WHEN invoke THEN propagates the error`() = runTest {
// Arrange // Arrange
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null)) val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null))
coEvery { signHashesUseCase(any(), any()) } returns SignHashesError.NoSigningKey.left() coEvery { signUseCase(any<List<ByteArray>>(), any(), any()) } returns
SignHashesError.SigningFailed(message = "canceled").left()
// Act // Act
val result = useCase(userWallet, contact) val result = useCase(userWallet, contact)
// Assert // Assert
assertThat(result.leftOrNull()).isEqualTo(SignHashesError.NoSigningKey) assertThat(result.leftOrNull()).isEqualTo(SignHashesError.SigningFailed(message = "canceled"))
} }
private fun contact(vararg entries: AddressEntry): Contact = Contact( private fun contact(vararg entries: AddressEntry): Contact = Contact(

View file

@ -12,7 +12,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.VerifyMessagesError import com.tangem.domain.transaction.error.VerifyMessagesError
import com.tangem.domain.transaction.usecase.VerifyMessagesUseCase import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
import com.tangem.utils.extensions.toHexString import com.tangem.utils.extensions.toHexString
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.every import io.mockk.every
@ -26,7 +26,7 @@ import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS)
class VerifyAddressEntriesUseCaseTest { class VerifyAddressEntriesUseCaseTest {
private val verifyMessagesUseCase: VerifyMessagesUseCase = mockk() private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase = mockk()
private val useCase = VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase) private val useCase = VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase)
private val userWallet: UserWallet = mockk() private val userWallet: UserWallet = mockk()

View file

@ -7,10 +7,11 @@ import com.tangem.domain.models.wallet.UserWallet
* Wallet master secp256k1 public key bytes, without any network derivation. Returns `null` when the * Wallet master secp256k1 public key bytes, without any network derivation. Returns `null` when the
* wallet is locked or has no secp256k1 key. * wallet is locked or has no secp256k1 key.
* *
* This is the single source of truth for the key used to sign ([SignHashesUseCase]) and verify * This is the single source of truth for the key a caller hands to [SignUseCase] and the key
* ([VerifyMessagesUseCase]) raw hashes, so both operations resolve to the very same key. * [VerifySecp256k1MessagesUseCase] verifies against, so both operations resolve to the very same key.
* The curve stays encapsulated here (callers receive plain bytes) so they don't depend on the card SDK.
*/ */
internal fun UserWallet.primarySecp256k1PublicKey(): ByteArray? = when (this) { fun UserWallet.primarySecp256k1PublicKey(): ByteArray? = when (this) {
is UserWallet.Cold -> scanResponse.card.wallets is UserWallet.Cold -> scanResponse.card.wallets
.firstOrNull { it.curve == EllipticCurve.Secp256k1 } .firstOrNull { it.curve == EllipticCurve.Secp256k1 }
?.publicKey ?.publicKey

View file

@ -1,62 +0,0 @@
package com.tangem.domain.transaction.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.common.Wallet
import com.tangem.common.CompletionResult
import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.card.models.TwinKey
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.SignHashesError
/**
* Signs a batch of raw [hashes] with the wallet's primary secp256k1 key in a single signing
* session one NFC tap for cold cards, one access-code unlock for hot wallets.
*
* The hashes are signed with the wallet master key without any network derivation, so every
* signature verifies against that single wallet public key regardless of which networks the hashed
* data refers to. Use it when several pieces of data must be attested with the same wallet identity
* in one user interaction (e.g. signing all address-book entries of a contact at once).
*
* Signatures are returned in the same order as the input [hashes].
*/
class SignHashesUseCase(
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val getHotTransactionSigner: (UserWallet.Hot) -> TransactionSigner,
) {
suspend operator fun invoke(
userWallet: UserWallet,
hashes: List<ByteArray>,
): Either<SignHashesError, List<ByteArray>> {
if (hashes.isEmpty()) return emptyList<ByteArray>().right()
val seedKey = userWallet.primarySecp256k1PublicKey() ?: return SignHashesError.NoSigningKey.left()
val publicKey = Wallet.PublicKey(seedKey = seedKey, derivationType = null)
val signer = when (userWallet) {
is UserWallet.Hot -> getHotTransactionSigner(userWallet)
is UserWallet.Cold -> getColdSigner(userWallet)
}
return when (val result = signer.sign(hashes, publicKey)) {
is CompletionResult.Success -> result.data.right()
is CompletionResult.Failure -> SignHashesError.SigningFailed(
message = result.error.message ?: "Unknown error",
).left()
}
}
private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner {
val card = userWallet.scanResponse.card
val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins
return cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
)
}
}

View file

@ -4,12 +4,14 @@ import arrow.core.Either
import arrow.core.left import arrow.core.left
import arrow.core.right import arrow.core.right
import com.tangem.blockchain.common.TransactionSigner import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.common.Wallet
import com.tangem.common.CompletionResult import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemError import com.tangem.common.core.TangemError
import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.card.models.TwinKey import com.tangem.domain.card.models.TwinKey
import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.transaction.error.SignHashesError
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
@ -37,6 +39,39 @@ class SignUseCase(
} }
} }
/**
* Signs a batch of raw [hashes] with [publicKey] in a single signing session one NFC tap for
* cold cards, one access-code unlock for hot wallets.
*
* [publicKey] is the wallet seed key the hashes are signed with, without any network derivation,
* so every signature verifies against that single public key regardless of which networks the
* hashed data refers to. Resolving which key to sign with (and its curve) is the caller's
* responsibility this use case is curve-agnostic and signs exactly the given hashes.
*
* Signatures are returned in the same order as the input [hashes]; an empty input yields an empty
* list without starting a signing session.
*/
suspend operator fun invoke(
hashes: List<ByteArray>,
publicKey: ByteArray,
userWallet: UserWallet,
): Either<SignHashesError, List<ByteArray>> {
if (hashes.isEmpty()) return emptyList<ByteArray>().right()
val signer = when (userWallet) {
is UserWallet.Hot -> getHotTransactionSigner(userWallet)
is UserWallet.Cold -> getColdSigner(userWallet)
}
val seedPublicKey = Wallet.PublicKey(seedKey = publicKey, derivationType = null)
return when (val result = signer.sign(hashes, seedPublicKey)) {
is CompletionResult.Success -> result.data.right()
is CompletionResult.Failure -> SignHashesError.SigningFailed(
message = result.error.message ?: "Unknown error",
).left()
}
}
private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner { private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner {
val card = userWallet.scanResponse.card val card = userWallet.scanResponse.card
val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins

View file

@ -10,12 +10,15 @@ import com.tangem.domain.transaction.error.VerifyMessagesError
/** /**
* Verifies each of the given [messages] against [userWallet]'s primary secp256k1 key the * Verifies each of the given [messages] against [userWallet]'s primary secp256k1 key the
* counterpart of [SignHashesUseCase]. * counterpart of [SignUseCase] for raw-hash signatures.
* *
* Pass the **original messages** (the pre-images), not their hashes: signing hashes a message with * Only secp256k1 is supported: the verifier hashes the message with SHA-256 and runs ECDSA, which
* SHA-256 before the elliptic-curve operation, so verification applies the same SHA-256 internally * mirrors how the card signs (`SHA-256(message)` then ECDSA). Other curves use a different hashing
* (via [CryptoUtils.verify]). [messages] and [signatures] are positional element `i` of one must * scheme, so this use case is deliberately curve-specific rather than parameterized.
* correspond to element `i` of the other. *
* Pass the **original messages** (the pre-images), not their hashes: the SHA-256 is applied
* internally (via [CryptoUtils.verify]). [messages] and [signatures] are positional element `i` of
* one must correspond to element `i` of the other.
* *
* Returns one [Boolean] per message, aligned to [messages] order: `result[i]` is `true` only when * Returns one [Boolean] per message, aligned to [messages] order: `result[i]` is `true` only when
* `signatures[i]` is a valid signature of `messages[i]`. A mismatch (tampered data, wrong wallet, * `signatures[i]` is a valid signature of `messages[i]`. A mismatch (tampered data, wrong wallet,
@ -23,7 +26,7 @@ import com.tangem.domain.transaction.error.VerifyMessagesError
* wallet's signing key being unavailable is a [VerifyMessagesError.NoSigningKey] failure (nothing can * wallet's signing key being unavailable is a [VerifyMessagesError.NoSigningKey] failure (nothing can
* be verified) rather than a list of `false`s. * be verified) rather than a list of `false`s.
*/ */
class VerifyMessagesUseCase { class VerifySecp256k1MessagesUseCase {
operator fun invoke( operator fun invoke(
userWallet: UserWallet, userWallet: UserWallet,

View file

@ -1,131 +0,0 @@
package com.tangem.domain.transaction.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.common.Wallet
import com.tangem.common.CompletionResult
import com.tangem.common.card.EllipticCurve
import com.tangem.common.core.TangemError
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.models.MobileWallet
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.SignHashesError
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class SignHashesUseCaseTest {
private val cardSdkConfigRepository: CardSdkConfigRepository = mockk()
private val getHotTransactionSigner: (UserWallet.Hot) -> TransactionSigner = mockk()
private val useCase = SignHashesUseCase(
cardSdkConfigRepository = cardSdkConfigRepository,
getHotTransactionSigner = getHotTransactionSigner,
)
private val hashes = listOf(byteArrayOf(1, 2, 3), byteArrayOf(4, 5, 6))
private val signatures = listOf(byteArrayOf(7, 8, 9), byteArrayOf(10, 11, 12))
@Test
fun `GIVEN cold wallet with secp256k1 key WHEN invoke THEN signs hashes with common signer`() = runTest {
// Arrange
val coldWallet = MockUserWalletFactory.create()
val signer: TransactionSigner = mockk()
val publicKeySlot = slot<Wallet.PublicKey>()
every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer
coEvery { signer.sign(eq(hashes), capture(publicKeySlot)) } returns CompletionResult.Success(signatures)
// Act
val result = useCase(coldWallet, hashes)
// Assert
assertThat(result.getOrNull()).isEqualTo(signatures)
// Wallet master secp256k1 key is used, without network derivation
assertThat(publicKeySlot.captured.seedKey).isEqualTo(EllipticCurve.Secp256k1.name.toByteArray())
assertThat(publicKeySlot.captured.derivationType).isNull()
// Card is not backed up (backupStatus == null) and not a twin, so its id is passed to the signer
verify(exactly = 1) { cardSdkConfigRepository.getCommonSigner(cardId = coldWallet.cardId, twinKey = null) }
}
@Test
fun `GIVEN hot wallet with secp256k1 key WHEN invoke THEN signs hashes with hot signer`() = runTest {
// Arrange
val hotWallet = mockk<UserWallet.Hot> {
every { wallets } returns listOf(mobileWallet(curve = EllipticCurve.Secp256k1, publicKey = byteArrayOf(42)))
}
val signer: TransactionSigner = mockk()
val publicKeySlot = slot<Wallet.PublicKey>()
every { getHotTransactionSigner(hotWallet) } returns signer
coEvery { signer.sign(eq(hashes), capture(publicKeySlot)) } returns CompletionResult.Success(signatures)
// Act
val result = useCase(hotWallet, hashes)
// Assert
assertThat(result.getOrNull()).isEqualTo(signatures)
assertThat(publicKeySlot.captured.seedKey).isEqualTo(byteArrayOf(42))
assertThat(publicKeySlot.captured.derivationType).isNull()
verify(exactly = 1) { getHotTransactionSigner(hotWallet) }
}
@Test
fun `GIVEN locked wallet without signing key WHEN invoke THEN returns NoSigningKey`() = runTest {
// Arrange
val lockedWallet = mockk<UserWallet.Hot> {
every { wallets } returns null
}
// Act
val result = useCase(lockedWallet, hashes)
// Assert
assertThat(result.leftOrNull()).isEqualTo(SignHashesError.NoSigningKey)
verify(exactly = 0) { getHotTransactionSigner(any()) }
}
@Test
fun `GIVEN signer fails WHEN invoke THEN returns SigningFailed with error message`() = runTest {
// Arrange
val coldWallet = MockUserWalletFactory.create()
val signer: TransactionSigner = mockk()
val error: TangemError = mockk { every { message } returns "Signing canceled" }
every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer
coEvery { signer.sign(any<List<ByteArray>>(), any()) } returns CompletionResult.Failure(error)
// Act
val result = useCase(coldWallet, hashes)
// Assert
assertThat(result.leftOrNull()).isEqualTo(SignHashesError.SigningFailed(message = "Signing canceled"))
}
@Test
fun `GIVEN empty hashes WHEN invoke THEN returns empty list without signing`() = runTest {
// Arrange
val coldWallet = MockUserWalletFactory.create()
// Act
val result = useCase(coldWallet, hashes = emptyList())
// Assert
assertThat(result.getOrNull()).isEmpty()
verify(exactly = 0) { cardSdkConfigRepository.getCommonSigner(any(), any()) }
verify(exactly = 0) { getHotTransactionSigner(any()) }
}
private fun mobileWallet(curve: EllipticCurve, publicKey: ByteArray): MobileWallet = MobileWallet(
publicKey = publicKey,
chainCode = null,
curve = curve,
derivedKeys = emptyMap(),
)
}

View file

@ -0,0 +1,153 @@
package com.tangem.domain.transaction.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.common.Wallet
import com.tangem.blockchain.common.WalletManager
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemError
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.SignHashesError
import com.tangem.domain.walletmanager.WalletManagersFacade
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class SignUseCaseTest {
private val cardSdkConfigRepository: CardSdkConfigRepository = mockk()
private val walletManagersFacade: WalletManagersFacade = mockk()
private val getHotTransactionSigner: (UserWallet.Hot) -> TransactionSigner = mockk()
private val useCase = SignUseCase(
cardSdkConfigRepository = cardSdkConfigRepository,
walletManagersFacade = walletManagersFacade,
getHotTransactionSigner = getHotTransactionSigner,
)
private val publicKey = byteArrayOf(42, 43, 44)
private val hashes = listOf(byteArrayOf(1, 2, 3), byteArrayOf(4, 5, 6))
private val signatures = listOf(byteArrayOf(7, 8, 9), byteArrayOf(10, 11, 12))
@Test
fun `GIVEN cold wallet WHEN sign single hash THEN signs with the wallet-manager key for the network`() = runTest {
// Arrange
val coldWallet = MockUserWalletFactory.create()
val network: Network = mockk()
val signer: TransactionSigner = mockk()
val walletManagerKey = Wallet.PublicKey(seedKey = byteArrayOf(50, 51), derivationType = null)
val walletManager: WalletManager = mockk { every { wallet } returns mockk { every { publicKey } returns walletManagerKey } }
val hash = byteArrayOf(1, 2, 3)
val signature = byteArrayOf(9, 9)
every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer
coEvery { walletManagersFacade.getOrCreateWalletManager(coldWallet.walletId, network) } returns walletManager
coEvery { signer.sign(eq(hash), eq(walletManagerKey)) } returns CompletionResult.Success(signature)
// Act
val result = useCase(hash = hash, userWallet = coldWallet, network = network)
// Assert
assertThat(result.getOrNull()).isEqualTo(signature)
}
@Test
fun `GIVEN signer fails WHEN sign single hash THEN returns the TangemError`() = runTest {
// Arrange
val coldWallet = MockUserWalletFactory.create()
val network: Network = mockk()
val signer: TransactionSigner = mockk()
val walletManagerKey = Wallet.PublicKey(seedKey = byteArrayOf(50, 51), derivationType = null)
val walletManager: WalletManager = mockk { every { wallet } returns mockk { every { publicKey } returns walletManagerKey } }
val error: TangemError = mockk()
every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer
coEvery { walletManagersFacade.getOrCreateWalletManager(coldWallet.walletId, network) } returns walletManager
coEvery { signer.sign(any<ByteArray>(), any()) } returns CompletionResult.Failure(error)
// Act
val result = useCase(hash = byteArrayOf(1), userWallet = coldWallet, network = network)
// Assert
assertThat(result.leftOrNull()).isEqualTo(error)
}
@Test
fun `GIVEN cold wallet WHEN sign hashes THEN signs with common signer and given key`() = runTest {
// Arrange
val coldWallet = MockUserWalletFactory.create()
val signer: TransactionSigner = mockk()
val publicKeySlot = slot<Wallet.PublicKey>()
every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer
coEvery { signer.sign(eq(hashes), capture(publicKeySlot)) } returns CompletionResult.Success(signatures)
// Act
val result = useCase(hashes = hashes, publicKey = publicKey, userWallet = coldWallet)
// Assert
assertThat(result.getOrNull()).isEqualTo(signatures)
// The caller-provided key is used verbatim, with no network derivation
assertThat(publicKeySlot.captured.seedKey).isEqualTo(publicKey)
assertThat(publicKeySlot.captured.derivationType).isNull()
// Card is not backed up (backupStatus == null) and not a twin, so its id is passed to the signer
verify(exactly = 1) { cardSdkConfigRepository.getCommonSigner(cardId = coldWallet.cardId, twinKey = null) }
}
@Test
fun `GIVEN hot wallet WHEN sign hashes THEN signs with hot signer and given key`() = runTest {
// Arrange
val hotWallet = mockk<UserWallet.Hot>()
val signer: TransactionSigner = mockk()
val publicKeySlot = slot<Wallet.PublicKey>()
every { getHotTransactionSigner(hotWallet) } returns signer
coEvery { signer.sign(eq(hashes), capture(publicKeySlot)) } returns CompletionResult.Success(signatures)
// Act
val result = useCase(hashes = hashes, publicKey = publicKey, userWallet = hotWallet)
// Assert
assertThat(result.getOrNull()).isEqualTo(signatures)
assertThat(publicKeySlot.captured.seedKey).isEqualTo(publicKey)
verify(exactly = 1) { getHotTransactionSigner(hotWallet) }
}
@Test
fun `GIVEN signer fails WHEN sign hashes THEN returns SigningFailed with error message`() = runTest {
// Arrange
val coldWallet = MockUserWalletFactory.create()
val signer: TransactionSigner = mockk()
val error: TangemError = mockk { every { message } returns "Signing canceled" }
every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer
coEvery { signer.sign(any<List<ByteArray>>(), any()) } returns CompletionResult.Failure(error)
// Act
val result = useCase(hashes = hashes, publicKey = publicKey, userWallet = coldWallet)
// Assert
assertThat(result.leftOrNull()).isEqualTo(SignHashesError.SigningFailed(message = "Signing canceled"))
}
@Test
fun `GIVEN empty hashes WHEN sign hashes THEN returns empty list without signing`() = runTest {
// Arrange
val coldWallet = MockUserWalletFactory.create()
// Act
val result = useCase(hashes = emptyList(), publicKey = publicKey, userWallet = coldWallet)
// Assert
assertThat(result.getOrNull()).isEmpty()
verify(exactly = 0) { cardSdkConfigRepository.getCommonSigner(any(), any()) }
verify(exactly = 0) { getHotTransactionSigner(any()) }
}
}

View file

@ -16,9 +16,9 @@ import org.junit.jupiter.api.TestInstance
import java.security.MessageDigest import java.security.MessageDigest
@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class VerifyMessagesUseCaseTest { internal class VerifySecp256k1MessagesUseCaseTest {
private val useCase = VerifyMessagesUseCase() private val useCase = VerifySecp256k1MessagesUseCase()
// A valid secp256k1 key pair. The card signs the raw SHA-256 digest of each message. // A valid secp256k1 key pair. The card signs the raw SHA-256 digest of each message.
private val privateKey = "FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632550".hexToBytes() private val privateKey = "FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632550".hexToBytes()