Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-16 11:54:42 +01:00
commit 4d2e8812de
17 changed files with 872 additions and 2 deletions

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

@ -0,0 +1,15 @@
package com.tangem.domain.addressbook.model
/**
* Outcome of verifying a [Contact]'s [AddressEntry]s against the wallet that signed them.
*
* @property valid entries whose signature was produced by the wallet these should be shown.
* @property invalid entries that failed verification (tampered, signed by another wallet, or carrying
* a missing/malformed signature) these should be hidden.
*/
data class AddressEntriesVerification(
val valid: List<AddressEntry>,
val invalid: List<AddressEntry>,
) {
val areAllInvalid: Boolean get() = valid.isEmpty() && invalid.isNotEmpty()
}

View file

@ -0,0 +1,22 @@
package com.tangem.domain.addressbook.usecase
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.Contact
/**
* Builds the canonical bytes that are signed for a single [AddressEntry]:
* `address + networkId + memo + contactId + name`.
*
* Shared by [SignAddressEntriesUseCase] (which hashes and signs it) and [VerifyAddressEntriesUseCase]
* (which verifies the signature against it), so the signed and verified payloads can never diverge.
*/
internal fun buildAddressEntryPayload(contact: Contact, entry: AddressEntry): ByteArray {
val payload = buildString {
append(entry.address)
append(entry.networkId.value)
append(entry.memo.orEmpty())
append(contact.id.value)
append(contact.name.value)
}
return payload.toByteArray(Charsets.UTF_8)
}

View file

@ -0,0 +1,41 @@
package com.tangem.domain.addressbook.usecase
import arrow.core.Either
import arrow.core.raise.either
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.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 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 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 = signUseCase(hashes = hashes, publicKey = publicKey, userWallet = userWallet).bind()
val signedEntries = entries.mapIndexed { index, entry ->
entry.copy(signature = signatures[index].toHexString())
}
contact.copy(addressEntries = signedEntries)
}
private fun hashEntry(contact: Contact, entry: AddressEntry): ByteArray {
val payload = buildAddressEntryPayload(contact, entry)
return MessageDigest.getInstance("SHA-256").digest(payload)
}
}

View file

@ -0,0 +1,55 @@
package com.tangem.domain.addressbook.usecase
import arrow.core.Either
import arrow.core.right
import com.tangem.domain.addressbook.model.AddressEntriesVerification
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.VerifySecp256k1MessagesUseCase
import com.tangem.utils.extensions.hexToBytesOrNull
/**
* Verifies each [AddressEntry] of a [Contact] against [userWallet] and partitions them into the ones
* whose signature was produced by that wallet ([AddressEntriesVerification.valid]) and the ones that
* were not ([AddressEntriesVerification.invalid]). The counterpart of [SignAddressEntriesUseCase].
*
* An entry is **invalid** when its signature fails verification or is missing/malformed (non-hex);
* such entries should be hidden from the user. Both partitions preserve the contact's original entry
* order. An empty contact yields two empty lists. The wallet's signing key being unavailable surfaces
* as a [VerifyMessagesError.NoSigningKey] failure (the entries cannot be verified at all).
*
* Each entry is verified against the exact bytes that were signed (see [buildAddressEntryPayload]).
*/
class VerifyAddressEntriesUseCase(
private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase,
) {
operator fun invoke(
userWallet: UserWallet,
contact: Contact,
): Either<VerifyMessagesError, AddressEntriesVerification> {
val entries = contact.addressEntries
if (entries.isEmpty()) return AddressEntriesVerification(valid = emptyList(), invalid = emptyList()).right()
// Entries with a malformed (non-hex) signature can't be verified — they are invalid by format.
val wellFormed = entries.mapNotNull { entry ->
entry.signature.hexToBytesOrNull()?.let { signature -> entry to signature }
}
val messages = wellFormed.map { (entry, _) -> buildAddressEntryPayload(contact, entry) }
val signatures = wellFormed.map { (_, signature) -> signature }
return verifyMessagesUseCase(userWallet = userWallet, messages = messages, signatures = signatures)
.map { flags ->
val validIds = wellFormed
.filterIndexed { index, _ -> flags[index] }
.mapTo(HashSet()) { (entry, _) -> entry.id }
AddressEntriesVerification(
valid = entries.filter { it.id in validIds },
invalid = entries.filterNot { it.id in validIds },
)
}
}
}

View file

@ -0,0 +1,143 @@
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
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
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.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
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.security.MessageDigest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SignAddressEntriesUseCaseTest {
private val signUseCase: SignUseCase = mockk()
private val useCase = SignAddressEntriesUseCase(signUseCase = signUseCase)
// 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(signUseCase)
}
@Test
fun `GIVEN contact with entries WHEN invoke THEN every entry receives its signature`() = runTest {
// Arrange
val contact = contact(
entry(id = "addr-1", address = "0xabc", memo = "memo"),
entry(id = "addr-2", address = "0xdef", memo = null),
)
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte()))
val hashesSlot = slot<List<ByteArray>>()
val publicKeySlot = slot<ByteArray>()
coEvery {
signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet))
} returns signatures.right()
// Act
val result = useCase(userWallet, contact)
// Assert
// Signatures are applied in entry order, hex-encoded; all other fields are preserved
val expected = contact.copy(
addressEntries = listOf(
contact.addressEntries[0].copy(signature = signatures[0].toHexString()),
contact.addressEntries[1].copy(signature = signatures[1].toHexString()),
),
)
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(
expectedHash(contact, contact.addressEntries[0]).toHexString(),
expectedHash(contact, contact.addressEntries[1]).toHexString(),
)
.inOrder()
}
@Test
fun `GIVEN contact with no entries WHEN invoke THEN returns contact unchanged without signing`() = runTest {
// Arrange
val contact = contact()
// Act
val result = useCase(userWallet, contact)
// Assert
assertThat(result.getOrNull()).isEqualTo(contact)
coVerify(exactly = 0) { signUseCase(any<List<ByteArray>>(), any(), any()) }
}
@Test
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 { signUseCase(any<List<ByteArray>>(), any(), any()) } returns
SignHashesError.SigningFailed(message = "canceled").left()
// Act
val result = useCase(userWallet, contact)
// Assert
assertThat(result.leftOrNull()).isEqualTo(SignHashesError.SigningFailed(message = "canceled"))
}
private fun contact(vararg entries: AddressEntry): Contact = Contact(
id = ContactId("contact-1"),
walletId = UserWalletId("011"),
name = requireNotNull(ContactName("Alice").getOrNull()),
addressEntries = entries.toList(),
)
private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry(
id = AddressEntryId(id),
address = address,
networkId = Network.RawID("ethereum"),
memo = memo,
signature = "",
)
private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray {
val payload = entry.address + entry.networkId.value + entry.memo.orEmpty() +
contact.id.value + contact.name.value
return MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8))
}
}

View file

@ -0,0 +1,168 @@
package com.tangem.domain.addressbook.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
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.VerifySecp256k1MessagesUseCase
import com.tangem.utils.extensions.toHexString
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class VerifyAddressEntriesUseCaseTest {
private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase = mockk()
private val useCase = VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase)
private val userWallet: UserWallet = mockk()
@BeforeEach
fun resetMocks() {
clearMocks(verifyMessagesUseCase)
}
@Test
fun `GIVEN contact with entries WHEN invoke THEN verifies each entry payload and its signature`() {
// Arrange
val contact = contact(
entry(id = "addr-1", address = "0xabc", memo = "memo", signature = "AABB"),
entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"),
)
val messagesSlot = slot<List<ByteArray>>()
val signaturesSlot = slot<List<ByteArray>>()
every {
verifyMessagesUseCase(eq(userWallet), capture(messagesSlot), capture(signaturesSlot))
} returns listOf(true, true).right()
// Act
val result = useCase(userWallet, contact)
// Assert
// Each entry is verified against address + networkId + memo + contactId + name
assertThat(messagesSlot.captured.map { String(it) })
.containsExactly(
expectedPayload(contact, contact.addressEntries[0]),
expectedPayload(contact, contact.addressEntries[1]),
)
.inOrder()
// Hex signatures are decoded to bytes, in entry order
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder()
}
@Test
fun `GIVEN some entries fail verification WHEN invoke THEN partitions them preserving order`() {
// Arrange
val valid1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")
val invalid = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD")
val valid2 = entry(id = "addr-3", address = "0xghi", memo = null, signature = "EEFF")
val contact = contact(valid1, invalid, valid2)
every { verifyMessagesUseCase(any(), any(), any()) } returns listOf(true, false, true).right()
// Act
val result = useCase(userWallet, contact).getOrNull()
// Assert
assertThat(result!!.valid).containsExactly(valid1, valid2).inOrder()
assertThat(result.invalid).containsExactly(invalid)
assertThat(result.areAllInvalid).isFalse()
}
@Test
fun `GIVEN malformed signature WHEN invoke THEN that entry is invalid and excluded from verification`() {
// Arrange
val malformed = entry(id = "addr-1", address = "0xabc", memo = null, signature = "not-hex")
val signed = entry(id = "addr-2", address = "0xdef", memo = null, signature = "AABB")
val contact = contact(malformed, signed)
val signaturesSlot = slot<List<ByteArray>>()
every {
verifyMessagesUseCase(eq(userWallet), any(), capture(signaturesSlot))
} returns listOf(true).right()
// Act
val result = useCase(userWallet, contact).getOrNull()
// Assert
// Only the well-formed entry is passed to verification
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB")
assertThat(result!!.valid).containsExactly(signed)
assertThat(result.invalid).containsExactly(malformed)
}
@Test
fun `GIVEN every entry is invalid WHEN invoke THEN allInvalid is true`() {
// Arrange
val entry1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")
val entry2 = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD")
val contact = contact(entry1, entry2)
every { verifyMessagesUseCase(any(), any(), any()) } returns listOf(false, false).right()
// Act
val result = useCase(userWallet, contact).getOrNull()
// Assert
assertThat(result!!.valid).isEmpty()
assertThat(result.invalid).containsExactly(entry1, entry2).inOrder()
assertThat(result.areAllInvalid).isTrue()
}
@Test
fun `GIVEN contact with no entries WHEN invoke THEN returns empty partition without verifying`() {
// Arrange
val contact = contact()
// Act
val result = useCase(userWallet, contact).getOrNull()
// Assert
assertThat(result!!.valid).isEmpty()
assertThat(result.invalid).isEmpty()
assertThat(result.areAllInvalid).isFalse()
verify(exactly = 0) { verifyMessagesUseCase(any(), any(), any()) }
}
@Test
fun `GIVEN verifyMessagesUseCase returns error WHEN invoke THEN propagates the error`() {
// Arrange
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))
every { verifyMessagesUseCase(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left()
// Act
val result = useCase(userWallet, contact)
// Assert
assertThat(result.leftOrNull()).isEqualTo(VerifyMessagesError.NoSigningKey)
}
private fun contact(vararg entries: AddressEntry): Contact = Contact(
id = ContactId("contact-1"),
walletId = UserWalletId("011"),
name = requireNotNull(ContactName("Alice").getOrNull()),
addressEntries = entries.toList(),
)
private fun entry(id: String, address: String, memo: String?, signature: String): AddressEntry = AddressEntry(
id = AddressEntryId(id),
address = address,
networkId = Network.RawID("ethereum"),
memo = memo,
signature = signature,
)
private fun expectedPayload(contact: Contact, entry: AddressEntry): String =
entry.address + entry.networkId.value + entry.memo.orEmpty() + contact.id.value + contact.name.value
}

View file

@ -0,0 +1,10 @@
package com.tangem.domain.transaction.error
sealed class SignHashesError {
/** The wallet has no usable signing key (e.g. it is locked or has no secp256k1 key). */
data object NoSigningKey : SignHashesError()
/** The signing session failed or was canceled by the user. */
data class SigningFailed(val message: String) : SignHashesError()
}

View file

@ -0,0 +1,7 @@
package com.tangem.domain.transaction.error
sealed class VerifyMessagesError {
/** The wallet has no usable signing key (e.g. it is locked or has no secp256k1 key). */
data object NoSigningKey : VerifyMessagesError()
}

View file

@ -0,0 +1,21 @@
package com.tangem.domain.transaction.usecase
import com.tangem.common.card.EllipticCurve
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 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.
*/
fun UserWallet.primarySecp256k1PublicKey(): ByteArray? = when (this) {
is UserWallet.Cold -> scanResponse.card.wallets
.firstOrNull { it.curve == EllipticCurve.Secp256k1 }
?.publicKey
is UserWallet.Hot -> wallets
?.firstOrNull { it.curve == EllipticCurve.Secp256k1 }
?.publicKey
}

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

@ -0,0 +1,50 @@
package com.tangem.domain.transaction.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.common.card.EllipticCurve
import com.tangem.crypto.CryptoUtils
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.VerifyMessagesError
/**
* Verifies each of the given [messages] against [userWallet]'s primary secp256k1 key the
* counterpart of [SignUseCase] for raw-hash signatures.
*
* 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,
* malformed signature) or a missing signature for that index yields `false` for that element. The
* wallet's signing key being unavailable is a [VerifyMessagesError.NoSigningKey] failure (nothing can
* be verified) rather than a list of `false`s.
*/
class VerifySecp256k1MessagesUseCase {
operator fun invoke(
userWallet: UserWallet,
messages: List<ByteArray>,
signatures: List<ByteArray>,
): Either<VerifyMessagesError, List<Boolean>> {
val publicKey = userWallet.primarySecp256k1PublicKey()
?: return VerifyMessagesError.NoSigningKey.left()
val results = messages.mapIndexed { index, message ->
val signature = signatures.getOrNull(index) ?: return@mapIndexed false
CryptoUtils.verify(
publicKey = publicKey,
message = message,
signature = signature,
curve = EllipticCurve.Secp256k1,
)
}
return results.right()
}
}

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

@ -0,0 +1,121 @@
package com.tangem.domain.transaction.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.common.card.EllipticCurve
import com.tangem.crypto.CryptoUtils
import com.tangem.crypto.Secp256k1
import com.tangem.domain.models.MobileWallet
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.VerifyMessagesError
import com.tangem.utils.extensions.hexToBytes
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.security.MessageDigest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class VerifySecp256k1MessagesUseCaseTest {
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()
private lateinit var publicKey: ByteArray
@BeforeAll
fun initCrypto() {
CryptoUtils.initCrypto()
publicKey = CryptoUtils.generatePublicKey(privateKey, EllipticCurve.Secp256k1)
}
@Test
fun `GIVEN every signature matches its message WHEN invoke THEN all results are true`() {
// Arrange
val messages = listOf("first".toByteArray(), "second".toByteArray())
val signatures = messages.map(::sign)
// Act
val result = useCase(walletWithKey(publicKey), messages, signatures)
// Assert
assertThat(result.getOrNull()).containsExactly(true, true).inOrder()
}
@Test
fun `GIVEN one signature is for a different message WHEN invoke THEN only that result is false`() {
// Arrange
val messages = listOf("first".toByteArray(), "second".toByteArray())
val signatures = listOf(sign(messages[0]), sign("tampered".toByteArray()))
// Act
val result = useCase(walletWithKey(publicKey), messages, signatures)
// Assert
assertThat(result.getOrNull()).containsExactly(true, false).inOrder()
}
@Test
fun `GIVEN signature was made by another wallet WHEN invoke THEN result is false`() {
// Arrange
val message = "first".toByteArray()
val signatures = listOf(sign(message))
val otherPublicKey = CryptoUtils.generatePublicKey(
"589AEAE0EF93D7A0D7DAA8EB67E96AB02C2D8E5C0FB3D5F8BB2A03B6B2C2DF89".hexToBytes(),
EllipticCurve.Secp256k1,
)
// Act
val result = useCase(walletWithKey(otherPublicKey), listOf(message), signatures)
// Assert
assertThat(result.getOrNull()).containsExactly(false)
}
@Test
fun `GIVEN fewer signatures than messages WHEN invoke THEN missing ones are false`() {
// Arrange
val messages = listOf("first".toByteArray(), "second".toByteArray())
val signatures = listOf(sign(messages[0]))
// Act
val result = useCase(walletWithKey(publicKey), messages, signatures)
// Assert
assertThat(result.getOrNull()).containsExactly(true, false).inOrder()
}
@Test
fun `GIVEN no messages WHEN invoke THEN returns empty list`() {
// Act
val result = useCase(walletWithKey(publicKey), messages = emptyList(), signatures = emptyList())
// Assert
assertThat(result.getOrNull()).isEmpty()
}
@Test
fun `GIVEN locked wallet without signing key WHEN invoke THEN returns NoSigningKey`() {
// Arrange
val lockedWallet = mockk<UserWallet.Hot> { every { wallets } returns null }
// Act
val result = useCase(lockedWallet, listOf("first".toByteArray()), listOf(byteArrayOf(1)))
// Assert
assertThat(result.leftOrNull()).isEqualTo(VerifyMessagesError.NoSigningKey)
}
/** Signs the raw SHA-256 digest of [message], mirroring what a Tangem card produces. */
private fun sign(message: ByteArray): ByteArray {
val hash = MessageDigest.getInstance("SHA-256").digest(message)
return Secp256k1.ecdsaSignDigest(hash, privateKey)
}
private fun walletWithKey(key: ByteArray): UserWallet.Hot = mockk {
every { wallets } returns listOf(
MobileWallet(publicKey = key, chainCode = null, curve = EllipticCurve.Secp256k1, derivedKeys = emptyMap()),
)
}
}