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.tokens.GetNetworkAddressesUseCase
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.Provides
import dagger.hilt.InstallIn
@ -29,7 +29,9 @@ object AddressBookDomainModule {
@Provides
@Singleton
fun provideVerifyAddressEntriesUseCase(verifyMessagesUseCase: VerifyMessagesUseCase): VerifyAddressEntriesUseCase {
fun provideVerifyAddressEntriesUseCase(
verifyMessagesUseCase: VerifySecp256k1MessagesUseCase,
): VerifyAddressEntriesUseCase {
return VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase)
}
}

View file

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

View file

@ -83,7 +83,13 @@ internal class WcSolanaMessageSignUseCaseTest {
assertTrue(result.isLeft())
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()) }
}
@ -92,7 +98,13 @@ internal class WcSolanaMessageSignUseCaseTest {
runTest(UnconfinedTestDispatcher()) {
// Arrange
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()
val useCase = createUseCase(rawMessage = message.encodeBase58())

View file

@ -23,5 +23,6 @@ dependencies {
// region Test libraries
testImplementation(projects.test.core)
testImplementation(projects.test.mock)
testImplementation(projects.common.test)
// 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.models.wallet.UserWallet
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 java.security.MessageDigest
/**
* Signs every [AddressEntry] of a [Contact] with the wallet's primary key in a single signing
* session (one card tap). Each entry is hashed as `SHA-256(address + networkId + memo + contactId +
* name)` and the produced signature is stored back into [AddressEntry.signature].
* Signs every [AddressEntry] of a [Contact] with the wallet's primary secp256k1 key in a single
* signing session (one card tap). Each entry is hashed as `SHA-256(address + networkId + memo +
* contactId + name)` and the produced signature is stored back into [AddressEntry.signature].
*/
class SignAddressEntriesUseCase(
private val signHashesUseCase: SignHashesUseCase,
private val signUseCase: SignUseCase,
) {
suspend operator fun invoke(userWallet: UserWallet, contact: Contact): Either<SignHashesError, Contact> = either {
val entries = contact.addressEntries
if (entries.isEmpty()) return@either contact
val publicKey = userWallet.primarySecp256k1PublicKey() ?: raise(SignHashesError.NoSigningKey)
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 ->
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.models.wallet.UserWallet
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
/**
@ -23,7 +23,7 @@ import com.tangem.utils.extensions.hexToBytesOrNull
* Each entry is verified against the exact bytes that were signed (see [buildAddressEntryPayload]).
*/
class VerifyAddressEntriesUseCase(
private val verifyMessagesUseCase: VerifyMessagesUseCase,
private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase,
) {
operator fun invoke(

View file

@ -3,6 +3,7 @@ package com.tangem.domain.addressbook.usecase
import arrow.core.left
import arrow.core.right
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.AddressEntryId
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.UserWalletId
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 io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
@ -28,14 +30,16 @@ import java.security.MessageDigest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SignAddressEntriesUseCaseTest {
private val signHashesUseCase: SignHashesUseCase = mockk()
private val useCase = SignAddressEntriesUseCase(signHashesUseCase = signHashesUseCase)
private val signUseCase: SignUseCase = mockk()
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
fun resetMocks() {
clearMocks(signHashesUseCase)
clearMocks(signUseCase)
}
@Test
@ -47,7 +51,10 @@ class SignAddressEntriesUseCaseTest {
)
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte()))
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
val result = useCase(userWallet, contact)
@ -61,6 +68,8 @@ class SignAddressEntriesUseCaseTest {
),
)
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
assertThat(hashesSlot.captured.map { it.toHexString() })
.containsExactly(
@ -80,20 +89,35 @@ class SignAddressEntriesUseCaseTest {
// Assert
assertThat(result.getOrNull()).isEqualTo(contact)
coVerify(exactly = 0) { signHashesUseCase(any(), any()) }
coVerify(exactly = 0) { signUseCase(any<List<ByteArray>>(), any(), any()) }
}
@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
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
val result = useCase(userWallet, contact)
// Assert
assertThat(result.leftOrNull()).isEqualTo(SignHashesError.NoSigningKey)
assertThat(result.leftOrNull()).isEqualTo(SignHashesError.SigningFailed(message = "canceled"))
}
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.UserWalletId
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 io.mockk.clearMocks
import io.mockk.every
@ -26,7 +26,7 @@ import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class VerifyAddressEntriesUseCaseTest {
private val verifyMessagesUseCase: VerifyMessagesUseCase = mockk()
private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase = mockk()
private val useCase = VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase)
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 is locked or has no secp256k1 key.
*
* This is the single source of truth for the key used to sign ([SignHashesUseCase]) and verify
* ([VerifyMessagesUseCase]) raw hashes, so both operations resolve to the very same key.
* This is the single source of truth for the key a caller hands to [SignUseCase] and the 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
.firstOrNull { it.curve == EllipticCurve.Secp256k1 }
?.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.right
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.common.Wallet
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemError
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.network.Network
import com.tangem.domain.transaction.error.SignHashesError
import com.tangem.domain.walletmanager.WalletManagersFacade
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 {
val card = userWallet.scanResponse.card
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
* 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
* SHA-256 before the elliptic-curve operation, so verification applies the same SHA-256 internally
* (via [CryptoUtils.verify]). [messages] and [signatures] are positional element `i` of one must
* correspond to element `i` of the other.
* Only secp256k1 is supported: the verifier hashes the message with SHA-256 and runs ECDSA, which
* mirrors how the card signs (`SHA-256(message)` then ECDSA). Other curves use a different hashing
* scheme, so this use case is deliberately curve-specific rather than parameterized.
*
* 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
* `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
* be verified) rather than a list of `false`s.
*/
class VerifyMessagesUseCase {
class VerifySecp256k1MessagesUseCase {
operator fun invoke(
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
@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.
private val privateKey = "FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632550".hexToBytes()